typeclaw 0.1.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/LICENSE +21 -0
- package/README.md +134 -0
- package/auth.schema.json +63 -0
- package/cron.schema.json +96 -0
- package/package.json +72 -0
- package/scripts/emit-base-dockerfile.ts +5 -0
- package/scripts/generate-schema.ts +34 -0
- package/secrets.schema.json +63 -0
- package/src/agent/auth.ts +119 -0
- package/src/agent/compaction.ts +35 -0
- package/src/agent/git-nudge.ts +95 -0
- package/src/agent/index.ts +451 -0
- package/src/agent/plugin-tools.ts +269 -0
- package/src/agent/reload-tool.ts +71 -0
- package/src/agent/self.ts +45 -0
- package/src/agent/session-origin.ts +288 -0
- package/src/agent/subagents.ts +253 -0
- package/src/agent/system-prompt.ts +68 -0
- package/src/agent/tools/channel-fetch-attachment.ts +118 -0
- package/src/agent/tools/channel-history.ts +119 -0
- package/src/agent/tools/channel-reply.ts +182 -0
- package/src/agent/tools/channel-send.ts +212 -0
- package/src/agent/tools/ddg.ts +218 -0
- package/src/agent/tools/restart.ts +122 -0
- package/src/agent/tools/stream-snapshot.ts +181 -0
- package/src/agent/tools/webfetch/fetch.ts +102 -0
- package/src/agent/tools/webfetch/index.ts +1 -0
- package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
- package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
- package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
- package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
- package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
- package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
- package/src/agent/tools/webfetch/tool.ts +281 -0
- package/src/agent/tools/webfetch/types.ts +33 -0
- package/src/agent/tools/websearch.ts +96 -0
- package/src/agent/tools/wikipedia.ts +52 -0
- package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
- package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
- package/src/bundled-plugins/agent-browser/index.ts +179 -0
- package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
- package/src/bundled-plugins/agent-browser/shim.ts +152 -0
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
- package/src/bundled-plugins/guard/index.ts +26 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
- package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
- package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
- package/src/bundled-plugins/guard/policy.ts +18 -0
- package/src/bundled-plugins/memory/README.md +71 -0
- package/src/bundled-plugins/memory/append-tool.ts +84 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
- package/src/bundled-plugins/memory/dreaming.ts +470 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
- package/src/bundled-plugins/memory/index.ts +238 -0
- package/src/bundled-plugins/memory/load-memory.ts +122 -0
- package/src/bundled-plugins/memory/memory-logger.ts +257 -0
- package/src/bundled-plugins/memory/secret-detector.ts +49 -0
- package/src/bundled-plugins/memory/watermark.ts +15 -0
- package/src/bundled-plugins/security/index.ts +35 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
- package/src/bundled-plugins/security/policy.ts +9 -0
- package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
- package/src/channels/adapters/discord-bot-classify.ts +148 -0
- package/src/channels/adapters/discord-bot.ts +640 -0
- package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
- package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
- package/src/channels/adapters/kakaotalk-classify.ts +77 -0
- package/src/channels/adapters/kakaotalk.ts +622 -0
- package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
- package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
- package/src/channels/adapters/slack-bot-classify.ts +213 -0
- package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
- package/src/channels/adapters/slack-bot-time.ts +10 -0
- package/src/channels/adapters/slack-bot.ts +881 -0
- package/src/channels/adapters/telegram-bot-classify.ts +155 -0
- package/src/channels/adapters/telegram-bot-format.ts +309 -0
- package/src/channels/adapters/telegram-bot.ts +604 -0
- package/src/channels/engagement.ts +227 -0
- package/src/channels/index.ts +21 -0
- package/src/channels/manager.ts +292 -0
- package/src/channels/membership-cache.ts +116 -0
- package/src/channels/membership-from-history.ts +53 -0
- package/src/channels/membership.ts +30 -0
- package/src/channels/participants.ts +47 -0
- package/src/channels/persistence.ts +209 -0
- package/src/channels/reloadable.ts +28 -0
- package/src/channels/router.ts +1570 -0
- package/src/channels/schema.ts +273 -0
- package/src/channels/types.ts +160 -0
- package/src/cli/channel.ts +403 -0
- package/src/cli/compose-status.ts +95 -0
- package/src/cli/compose.ts +240 -0
- package/src/cli/hostd.ts +163 -0
- package/src/cli/index.ts +27 -0
- package/src/cli/init.ts +592 -0
- package/src/cli/logs.ts +38 -0
- package/src/cli/reload.ts +68 -0
- package/src/cli/restart.ts +66 -0
- package/src/cli/run.ts +77 -0
- package/src/cli/shell.ts +33 -0
- package/src/cli/start.ts +57 -0
- package/src/cli/status.ts +178 -0
- package/src/cli/stop.ts +31 -0
- package/src/cli/tui.ts +35 -0
- package/src/cli/ui.ts +110 -0
- package/src/commands/index.ts +74 -0
- package/src/compose/discover.ts +43 -0
- package/src/compose/index.ts +25 -0
- package/src/compose/logs.ts +162 -0
- package/src/compose/restart.ts +69 -0
- package/src/compose/start.ts +62 -0
- package/src/compose/status.ts +28 -0
- package/src/compose/stop.ts +43 -0
- package/src/config/config.ts +424 -0
- package/src/config/index.ts +25 -0
- package/src/config/providers.ts +234 -0
- package/src/config/reloadable.ts +47 -0
- package/src/container/index.ts +27 -0
- package/src/container/logs.ts +37 -0
- package/src/container/port.ts +137 -0
- package/src/container/shared.ts +290 -0
- package/src/container/shell.ts +58 -0
- package/src/container/start.ts +670 -0
- package/src/container/status.ts +76 -0
- package/src/container/stop.ts +120 -0
- package/src/container/verify-running.ts +149 -0
- package/src/cron/consumer.ts +138 -0
- package/src/cron/index.ts +54 -0
- package/src/cron/reloadable.ts +64 -0
- package/src/cron/scheduler.ts +200 -0
- package/src/cron/schema.ts +96 -0
- package/src/hostd/client.ts +113 -0
- package/src/hostd/daemon.ts +587 -0
- package/src/hostd/index.ts +25 -0
- package/src/hostd/paths.ts +82 -0
- package/src/hostd/portbroker-manager.ts +101 -0
- package/src/hostd/protocol.ts +48 -0
- package/src/hostd/spawn.ts +224 -0
- package/src/hostd/supervisor.ts +60 -0
- package/src/hostd/tailscale.ts +172 -0
- package/src/hostd/version.ts +115 -0
- package/src/init/dockerfile.ts +327 -0
- package/src/init/ensure-deps.ts +152 -0
- package/src/init/gitignore.ts +46 -0
- package/src/init/hatching.ts +60 -0
- package/src/init/index.ts +786 -0
- package/src/init/kakaotalk-auth.ts +114 -0
- package/src/init/models-dev.ts +130 -0
- package/src/init/oauth-login.ts +74 -0
- package/src/init/packagejson.ts +94 -0
- package/src/init/paths.ts +2 -0
- package/src/init/run-bun-install.ts +20 -0
- package/src/markdown/chunk.ts +299 -0
- package/src/markdown/index.ts +1 -0
- package/src/plugin/context.ts +40 -0
- package/src/plugin/define.ts +35 -0
- package/src/plugin/hooks.ts +204 -0
- package/src/plugin/index.ts +63 -0
- package/src/plugin/loader.ts +111 -0
- package/src/plugin/manager.ts +136 -0
- package/src/plugin/registry.ts +145 -0
- package/src/plugin/skills.ts +62 -0
- package/src/plugin/types.ts +172 -0
- package/src/portbroker/bind-with-forward.ts +102 -0
- package/src/portbroker/container-server.ts +305 -0
- package/src/portbroker/forward-result-bus.ts +36 -0
- package/src/portbroker/hostd-client.ts +443 -0
- package/src/portbroker/index.ts +33 -0
- package/src/portbroker/policy.ts +24 -0
- package/src/portbroker/proc-net-tcp.ts +72 -0
- package/src/portbroker/protocol.ts +39 -0
- package/src/reload/client.ts +59 -0
- package/src/reload/index.ts +3 -0
- package/src/reload/registry.ts +60 -0
- package/src/reload/types.ts +13 -0
- package/src/run/bundled-plugins.ts +24 -0
- package/src/run/channel-session-factory.ts +105 -0
- package/src/run/index.ts +432 -0
- package/src/run/plugin-runtime.ts +43 -0
- package/src/run/schema-with-plugins.ts +14 -0
- package/src/secrets/index.ts +13 -0
- package/src/secrets/migrate.ts +95 -0
- package/src/secrets/schema.ts +75 -0
- package/src/secrets/storage.ts +231 -0
- package/src/server/index.ts +436 -0
- package/src/sessions/index.ts +23 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/local-time.ts +21 -0
- package/src/shared/protocol.ts +25 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
- package/src/skills/typeclaw-config/SKILL.md +643 -0
- package/src/skills/typeclaw-cron/SKILL.md +159 -0
- package/src/skills/typeclaw-git/SKILL.md +89 -0
- package/src/skills/typeclaw-memory/SKILL.md +174 -0
- package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
- package/src/skills/typeclaw-plugins/SKILL.md +594 -0
- package/src/skills/typeclaw-skills/SKILL.md +246 -0
- package/src/stream/broker.ts +161 -0
- package/src/stream/index.ts +16 -0
- package/src/stream/types.ts +69 -0
- package/src/tui/client.ts +45 -0
- package/src/tui/format.ts +317 -0
- package/src/tui/index.ts +225 -0
- package/src/tui/theme.ts +41 -0
- package/typeclaw.schema.json +826 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { realpath } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { ACKNOWLEDGE_GUARDS, type GuardBlock, isGuardAcknowledged } from '../policy'
|
|
5
|
+
import { isSkillAuthoringAllowed } from './skill-authoring'
|
|
6
|
+
|
|
7
|
+
export const GUARD_NON_WORKSPACE_WRITE = 'nonWorkspaceWrite'
|
|
8
|
+
|
|
9
|
+
const AGENT_ROOT_WRITE_ALLOWLIST = new Set([
|
|
10
|
+
'AGENTS.md',
|
|
11
|
+
'IDENTITY.md',
|
|
12
|
+
'MEMORY.md',
|
|
13
|
+
'SOUL.md',
|
|
14
|
+
'USER.md',
|
|
15
|
+
'cron.json',
|
|
16
|
+
'package.json',
|
|
17
|
+
'typeclaw.json',
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
// `packages/` is a bun workspace root scaffolded at init (see
|
|
21
|
+
// src/init/index.ts#DIRECTORIES). Reusable systems and custom typeclaw
|
|
22
|
+
// plugins live there as standalone packages, so the agent must be able to
|
|
23
|
+
// write into `packages/<name>/...` without acknowledging the guard — same
|
|
24
|
+
// as `workspace/`, but for code intended to be reused rather than discarded.
|
|
25
|
+
const AGENT_ROOT_DIRECTORY_ALLOWLIST = new Set(['mounts', 'packages'])
|
|
26
|
+
|
|
27
|
+
export async function checkNonWorkspaceWriteGuard(options: {
|
|
28
|
+
tool: string
|
|
29
|
+
args: Record<string, unknown>
|
|
30
|
+
agentDir: string
|
|
31
|
+
}): Promise<GuardBlock | undefined> {
|
|
32
|
+
const { tool, args, agentDir } = options
|
|
33
|
+
if (tool !== 'write' && tool !== 'edit') return undefined
|
|
34
|
+
|
|
35
|
+
const rawPath = args.path
|
|
36
|
+
if (typeof rawPath !== 'string') return undefined
|
|
37
|
+
|
|
38
|
+
const targetPath = path.resolve(agentDir, rawPath)
|
|
39
|
+
const workspacePath = path.resolve(agentDir, 'workspace')
|
|
40
|
+
const [realTargetPath, realWorkspacePath] = await Promise.all([
|
|
41
|
+
resolveRealIntendedPath(targetPath),
|
|
42
|
+
resolveRealIntendedPath(workspacePath),
|
|
43
|
+
])
|
|
44
|
+
if (await isSkillAuthoringAllowed({ tool, args, agentDir })) return undefined
|
|
45
|
+
if (await isAllowedAgentRootWrite(agentDir, targetPath, realTargetPath)) return undefined
|
|
46
|
+
if (isInside(realWorkspacePath, realTargetPath)) return undefined
|
|
47
|
+
if (isGuardAcknowledged(args, GUARD_NON_WORKSPACE_WRITE)) return undefined
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
block: true,
|
|
51
|
+
reason: [
|
|
52
|
+
`Guard \`${GUARD_NON_WORKSPACE_WRITE}\` blocked ${tool} outside the workspace: ${targetPath}.`,
|
|
53
|
+
`The free-write zone is ${workspacePath}.`,
|
|
54
|
+
`Retry with \`${ACKNOWLEDGE_GUARDS}.${GUARD_NON_WORKSPACE_WRITE}: true\` only if this write is intentional.`,
|
|
55
|
+
].join(' '),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function isAllowedAgentRootWrite(agentDir: string, targetPath: string, realTargetPath: string): Promise<boolean> {
|
|
60
|
+
const resolvedAgentDir = path.resolve(agentDir)
|
|
61
|
+
if (path.dirname(targetPath) === resolvedAgentDir && AGENT_ROOT_WRITE_ALLOWLIST.has(path.basename(targetPath))) {
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const dir of AGENT_ROOT_DIRECTORY_ALLOWLIST) {
|
|
66
|
+
const rootDir = path.join(resolvedAgentDir, dir)
|
|
67
|
+
if (isInside(await resolveRealIntendedPath(rootDir), realTargetPath)) return true
|
|
68
|
+
}
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isInside(parent: string, child: string): boolean {
|
|
73
|
+
const relative = path.relative(parent, child)
|
|
74
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function resolveRealIntendedPath(absolutePath: string): Promise<string> {
|
|
78
|
+
const pending: string[] = []
|
|
79
|
+
let current = absolutePath
|
|
80
|
+
|
|
81
|
+
while (true) {
|
|
82
|
+
try {
|
|
83
|
+
const realCurrent = await realpath(current)
|
|
84
|
+
return path.join(realCurrent, ...pending.reverse())
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (!isNotFoundError(err)) throw err
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const parent = path.dirname(current)
|
|
90
|
+
if (parent === current) throw new Error(`could not resolve existing parent for ${absolutePath}`)
|
|
91
|
+
pending.push(path.basename(current))
|
|
92
|
+
current = parent
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isNotFoundError(err: unknown): boolean {
|
|
97
|
+
return err instanceof Error && 'code' in err && err.code === 'ENOENT'
|
|
98
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { readFile, realpath } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { GuardBlock } from '../policy'
|
|
5
|
+
|
|
6
|
+
export const GUARD_SKILL_AUTHORING = 'skillAuthoring'
|
|
7
|
+
|
|
8
|
+
export type SkillAuthoringDecision = GuardBlock | { allow: true } | undefined
|
|
9
|
+
|
|
10
|
+
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/
|
|
11
|
+
|
|
12
|
+
type SkillRoot = {
|
|
13
|
+
path: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function checkSkillAuthoringGuard(options: {
|
|
17
|
+
tool: string
|
|
18
|
+
args: Record<string, unknown>
|
|
19
|
+
agentDir: string
|
|
20
|
+
}): Promise<GuardBlock | undefined> {
|
|
21
|
+
const decision = await checkSkillAuthoringDecision(options)
|
|
22
|
+
return decision && 'block' in decision ? decision : undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function checkSkillAuthoringDecision(options: {
|
|
26
|
+
tool: string
|
|
27
|
+
args: Record<string, unknown>
|
|
28
|
+
agentDir: string
|
|
29
|
+
}): Promise<SkillAuthoringDecision> {
|
|
30
|
+
const { tool, args, agentDir } = options
|
|
31
|
+
if (tool !== 'write' && tool !== 'edit') return undefined
|
|
32
|
+
|
|
33
|
+
const rawPath = args.path
|
|
34
|
+
if (typeof rawPath !== 'string') return undefined
|
|
35
|
+
|
|
36
|
+
const targetPath = path.resolve(agentDir, rawPath)
|
|
37
|
+
const target = await resolveSkillTarget(agentDir, targetPath)
|
|
38
|
+
if (!target) return undefined
|
|
39
|
+
|
|
40
|
+
if (target.rest.length !== 2 || target.rest[1] !== 'SKILL.md') {
|
|
41
|
+
return block(tool, targetPath, 'skill writes must target exactly <skill-name>/SKILL.md')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const skillName = target.rest[0]
|
|
45
|
+
if (!skillName || !SKILL_NAME_PATTERN.test(skillName)) {
|
|
46
|
+
return block(tool, targetPath, `skill name must match ${SKILL_NAME_PATTERN}`)
|
|
47
|
+
}
|
|
48
|
+
if (skillName.startsWith('typeclaw-')) {
|
|
49
|
+
return block(tool, targetPath, 'the typeclaw- skill namespace is reserved for bundled skills')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const contentResult = await intendedContent(tool, args, targetPath)
|
|
53
|
+
if ('block' in contentResult) return contentResult
|
|
54
|
+
|
|
55
|
+
const frontmatter = parseFrontmatter(contentResult.content)
|
|
56
|
+
if (!frontmatter) {
|
|
57
|
+
return block(tool, targetPath, 'SKILL.md must start with YAML frontmatter')
|
|
58
|
+
}
|
|
59
|
+
if (frontmatter.name !== skillName) {
|
|
60
|
+
return block(tool, targetPath, `frontmatter name must match path segment ${skillName}`)
|
|
61
|
+
}
|
|
62
|
+
if (!frontmatter.description || frontmatter.description.trim().length === 0) {
|
|
63
|
+
return block(tool, targetPath, 'frontmatter description is required')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { allow: true }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function isSkillAuthoringAllowed(options: {
|
|
70
|
+
tool: string
|
|
71
|
+
args: Record<string, unknown>
|
|
72
|
+
agentDir: string
|
|
73
|
+
}): Promise<boolean> {
|
|
74
|
+
const decision = await checkSkillAuthoringDecision(options)
|
|
75
|
+
return decision !== undefined && 'allow' in decision
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function resolveSkillTarget(agentDir: string, targetPath: string): Promise<{ rest: string[] } | undefined> {
|
|
79
|
+
const roots: SkillRoot[] = [
|
|
80
|
+
{ path: path.join(agentDir, 'memory', 'skills') },
|
|
81
|
+
{ path: path.join(agentDir, '.agents', 'skills') },
|
|
82
|
+
]
|
|
83
|
+
const realTargetPath = await resolveRealIntendedPath(targetPath)
|
|
84
|
+
|
|
85
|
+
for (const root of roots) {
|
|
86
|
+
const realRootPath = await resolveRealIntendedPath(root.path)
|
|
87
|
+
if (!isInside(realRootPath, realTargetPath)) continue
|
|
88
|
+
return { rest: path.relative(realRootPath, realTargetPath).split(path.sep).filter(Boolean) }
|
|
89
|
+
}
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function intendedContent(
|
|
94
|
+
tool: string,
|
|
95
|
+
args: Record<string, unknown>,
|
|
96
|
+
targetPath: string,
|
|
97
|
+
): Promise<{ content: string } | GuardBlock> {
|
|
98
|
+
if (tool === 'write') {
|
|
99
|
+
const content = args.content
|
|
100
|
+
if (typeof content !== 'string') return block(tool, targetPath, 'write content must be a string')
|
|
101
|
+
return { content }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const edits = args.edits
|
|
105
|
+
if (!Array.isArray(edits)) return block(tool, targetPath, 'edit calls must include an edits array')
|
|
106
|
+
|
|
107
|
+
let content: string
|
|
108
|
+
try {
|
|
109
|
+
content = await readFile(targetPath, 'utf8')
|
|
110
|
+
} catch (err) {
|
|
111
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
112
|
+
return block(tool, targetPath, `could not read existing skill before edit: ${message}`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const edit of edits) {
|
|
116
|
+
if (!edit || typeof edit !== 'object') return block(tool, targetPath, 'each edit must be an object')
|
|
117
|
+
const { oldText, newText } = edit as Record<string, unknown>
|
|
118
|
+
if (typeof oldText !== 'string' || typeof newText !== 'string') {
|
|
119
|
+
return block(tool, targetPath, 'each edit must include string oldText and newText')
|
|
120
|
+
}
|
|
121
|
+
if (oldText.length === 0) return block(tool, targetPath, 'edit oldText must not be empty')
|
|
122
|
+
if (!content.includes(oldText)) return block(tool, targetPath, 'edit oldText was not found in existing skill')
|
|
123
|
+
content = content.replace(oldText, newText)
|
|
124
|
+
}
|
|
125
|
+
return { content }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseFrontmatter(content: string): { name?: string; description?: string } | undefined {
|
|
129
|
+
const normalized = content.replaceAll('\r\n', '\n')
|
|
130
|
+
if (!normalized.startsWith('---\n')) return undefined
|
|
131
|
+
const close = normalized.indexOf('\n---', 4)
|
|
132
|
+
if (close === -1) return undefined
|
|
133
|
+
|
|
134
|
+
const values: { name?: string; description?: string } = {}
|
|
135
|
+
for (const line of normalized.slice(4, close).split('\n')) {
|
|
136
|
+
const separator = line.indexOf(':')
|
|
137
|
+
if (separator === -1) continue
|
|
138
|
+
const key = line.slice(0, separator).trim()
|
|
139
|
+
if (key !== 'name' && key !== 'description') continue
|
|
140
|
+
values[key] = parseScalar(line.slice(separator + 1).trim())
|
|
141
|
+
}
|
|
142
|
+
return values
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseScalar(value: string): string {
|
|
146
|
+
if (value.length === 0) return ''
|
|
147
|
+
const quote = value[0]
|
|
148
|
+
if ((quote === '"' || quote === "'") && value.endsWith(quote)) return value.slice(1, -1)
|
|
149
|
+
return value
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function block(tool: string, targetPath: string, reason: string): GuardBlock {
|
|
153
|
+
return {
|
|
154
|
+
block: true,
|
|
155
|
+
reason: `Guard \`${GUARD_SKILL_AUTHORING}\` blocked ${tool} for ${targetPath}: ${reason}.`,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isInside(parent: string, child: string): boolean {
|
|
160
|
+
const relative = path.relative(parent, child)
|
|
161
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function resolveRealIntendedPath(absolutePath: string): Promise<string> {
|
|
165
|
+
const pending: string[] = []
|
|
166
|
+
let current = absolutePath
|
|
167
|
+
|
|
168
|
+
while (true) {
|
|
169
|
+
try {
|
|
170
|
+
const realCurrent = await realpath(current)
|
|
171
|
+
return path.join(realCurrent, ...pending.reverse())
|
|
172
|
+
} catch (err) {
|
|
173
|
+
if (!isNotFoundError(err)) throw err
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const parent = path.dirname(current)
|
|
177
|
+
if (parent === current) throw new Error(`could not resolve existing parent for ${absolutePath}`)
|
|
178
|
+
pending.push(path.basename(current))
|
|
179
|
+
current = parent
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isNotFoundError(err: unknown): boolean {
|
|
184
|
+
return err instanceof Error && 'code' in err && err.code === 'ENOENT'
|
|
185
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { ContentPart, ToolResult } from '@/plugin'
|
|
5
|
+
|
|
6
|
+
export const GUARD_UNCOMMITTED_CHANGES = 'uncommittedChanges'
|
|
7
|
+
|
|
8
|
+
const FILE_TOUCHING_TOOLS = new Set(['write', 'edit', 'bash'])
|
|
9
|
+
|
|
10
|
+
const RUNTIME_OWNED_PREFIXES = ['sessions/', 'memory/']
|
|
11
|
+
|
|
12
|
+
const WARNING_TEXT =
|
|
13
|
+
'\n\n[guard:uncommittedChanges] The worktree has uncommitted changes. Commit (or stash) them when this task is done — leaving stale changes around between turns risks losing work and confusing future commits.'
|
|
14
|
+
|
|
15
|
+
export type UncommittedChangesDeps = {
|
|
16
|
+
readStatus: (agentDir: string) => Promise<readonly string[] | null>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function checkUncommittedChangesAdvice(options: {
|
|
20
|
+
tool: string
|
|
21
|
+
agentDir: string
|
|
22
|
+
result: ToolResult
|
|
23
|
+
deps?: UncommittedChangesDeps
|
|
24
|
+
}): Promise<void> {
|
|
25
|
+
const { tool, agentDir, result } = options
|
|
26
|
+
if (!FILE_TOUCHING_TOOLS.has(tool)) return
|
|
27
|
+
if (!existsSync(join(agentDir, '.git'))) return
|
|
28
|
+
|
|
29
|
+
const deps = options.deps ?? defaultDeps
|
|
30
|
+
const status = await deps.readStatus(agentDir)
|
|
31
|
+
if (status === null) return
|
|
32
|
+
|
|
33
|
+
const dirty = status.filter((p) => !RUNTIME_OWNED_PREFIXES.some((prefix) => p.startsWith(prefix)))
|
|
34
|
+
if (dirty.length === 0) return
|
|
35
|
+
|
|
36
|
+
appendAdviceToContent(result.content, WARNING_TEXT)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parsePorcelain(stdout: string): string[] {
|
|
40
|
+
const out: string[] = []
|
|
41
|
+
for (const raw of stdout.split('\n')) {
|
|
42
|
+
if (raw.length < 4) continue
|
|
43
|
+
const rest = raw.slice(3)
|
|
44
|
+
const arrowIdx = rest.indexOf(' -> ')
|
|
45
|
+
out.push(arrowIdx === -1 ? rest : rest.slice(arrowIdx + 4))
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function appendAdviceToContent(content: ContentPart[], advice: string): void {
|
|
51
|
+
for (let i = content.length - 1; i >= 0; i--) {
|
|
52
|
+
const part = content[i]
|
|
53
|
+
if (part && part.type === 'text') {
|
|
54
|
+
content[i] = { ...part, text: `${part.text}${advice}` }
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
content.push({ type: 'text', text: advice.trimStart() })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const defaultDeps: UncommittedChangesDeps = {
|
|
62
|
+
async readStatus(agentDir) {
|
|
63
|
+
const bun = getBun()
|
|
64
|
+
if (!bun) return null
|
|
65
|
+
try {
|
|
66
|
+
const proc = bun.spawn({
|
|
67
|
+
cmd: ['git', 'status', '--porcelain=v1'],
|
|
68
|
+
cwd: agentDir,
|
|
69
|
+
stdout: 'pipe',
|
|
70
|
+
stderr: 'pipe',
|
|
71
|
+
})
|
|
72
|
+
const exit = await proc.exited
|
|
73
|
+
if (exit !== 0) return null
|
|
74
|
+
const text = await new Response(proc.stdout).text()
|
|
75
|
+
return parsePorcelain(text)
|
|
76
|
+
} catch {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getBun(): typeof Bun | null {
|
|
83
|
+
const g = globalThis as { Bun?: typeof Bun }
|
|
84
|
+
return g.Bun ?? null
|
|
85
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const ACKNOWLEDGE_GUARDS = 'acknowledgeGuards'
|
|
2
|
+
|
|
3
|
+
export type GuardBlock = { block: true; reason: string }
|
|
4
|
+
|
|
5
|
+
export function isGuardAcknowledged(args: Record<string, unknown>, guard: string): boolean {
|
|
6
|
+
const acknowledgements = args[ACKNOWLEDGE_GUARDS]
|
|
7
|
+
if (!acknowledgements || typeof acknowledgements !== 'object') return false
|
|
8
|
+
return (acknowledgements as Record<string, unknown>)[guard] === true
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export { GUARD_NON_WORKSPACE_WRITE, checkNonWorkspaceWriteGuard } from './policies/non-workspace-write'
|
|
12
|
+
export {
|
|
13
|
+
GUARD_SKILL_AUTHORING,
|
|
14
|
+
checkSkillAuthoringDecision,
|
|
15
|
+
checkSkillAuthoringGuard,
|
|
16
|
+
isSkillAuthoringAllowed,
|
|
17
|
+
} from './policies/skill-authoring'
|
|
18
|
+
export { GUARD_UNCOMMITTED_CHANGES, checkUncommittedChangesAdvice } from './policies/uncommitted-changes'
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# typeclaw-plugin-memory
|
|
2
|
+
|
|
3
|
+
The bundled memory plugin. Owns `MEMORY.md` (long-term memory) and `memory/yyyy-MM-dd.md` (daily streams) plus the two subagents that write them: `memory-logger` and `dreaming`.
|
|
4
|
+
|
|
5
|
+
This plugin is **auto-loaded** by every TypeClaw agent. There is no `plugins[]` entry to add and no opt-out. To configure it, add a `memory` block to `typeclaw.json`.
|
|
6
|
+
|
|
7
|
+
## Config
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"memory": {
|
|
12
|
+
"idleMs": 10000,
|
|
13
|
+
"bufferBytes": 100000,
|
|
14
|
+
"dreaming": { "schedule": "*/30 * * * *" }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
| Field | Default | Effect |
|
|
20
|
+
| -------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
21
|
+
| `memory.idleMs` | `10000` | Debounce window before `memory-logger` spawns after a prompt completes. Minimum `1000`. |
|
|
22
|
+
| `memory.bufferBytes` | `100000` | Size-based ceiling: spawns `memory-logger` when the transcript grows by this many bytes since the last run, even during continuous activity. `0` disables. Minimum `10000` when non-zero. |
|
|
23
|
+
| `memory.dreaming` | `{}` (cron job on) | Dreaming cron job is always registered. Override `schedule` to change when it fires. |
|
|
24
|
+
| `memory.dreaming.schedule` | `"*/30 * * * *"` | Five-field cron expression. Defaults to every 30 minutes; fires short-circuit with zero LLM cost when nothing sits past the watermark, so frequent no-op fires are cheap and let sporadic agents still consolidate while alive (`src/cron/scheduler.ts` has no catchup for missed fires). Second-level schedules are rejected to avoid noisy no-op dreaming loops. |
|
|
25
|
+
|
|
26
|
+
All fields are **restart-required** — the plugin reads them once at boot.
|
|
27
|
+
|
|
28
|
+
## What it contributes
|
|
29
|
+
|
|
30
|
+
| Kind | Name | Notes |
|
|
31
|
+
| -------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
32
|
+
| Subagent | `memory-logger` | Reads a parent transcript past a watermark and appends fragments to `memory/<today>.md`. 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 `git commit -m Dream` the result. Coalesced per `agentDir`. |
|
|
34
|
+
| Cron job | `__plugin_memory_dreaming` | `kind: 'prompt'`, `subagent: 'dreaming'`, scheduled per `memory.dreaming.schedule`. |
|
|
35
|
+
| Hook | `session.prompt` | Appends the rendered memory section (`# Memory`, `MEMORY.md`, undreamed stream tails) to `event.prompt`. |
|
|
36
|
+
| 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`. |
|
|
37
|
+
| 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). |
|
|
38
|
+
|
|
39
|
+
## Files on disk
|
|
40
|
+
|
|
41
|
+
- **`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.
|
|
42
|
+
- **`memory/yyyy-MM-dd.md`** — daily fragment 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.
|
|
43
|
+
- **`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.
|
|
44
|
+
- **`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.
|
|
45
|
+
|
|
46
|
+
`typeclaw init` does **not** scaffold these files. They appear when needed.
|
|
47
|
+
|
|
48
|
+
## How `session.idle` works
|
|
49
|
+
|
|
50
|
+
Core fires `session.idle` immediately after every `session.prompt()` completion (success or error). The plugin owns the debounce: it keeps a `Map<sessionId, Timeout>` and resets the timer on every event. When the timer fires, the plugin spawns `memory-logger` for that session.
|
|
51
|
+
|
|
52
|
+
If the user starts a new prompt before the timer fires, the next `session.idle` event resets the timer. If the user disconnects, `session.end` cancels the timer and fires `memory-logger` immediately so the final transcript is captured.
|
|
53
|
+
|
|
54
|
+
In channel sessions, the agent rarely goes idle long enough to trip the timer because new participant messages keep arriving. The size-based ceiling handles this: on every `session.idle` the plugin `fs.stat`s the transcript and compares against the size at the last memory-logger run. Once growth reaches `memory.bufferBytes`, the timer is cancelled and `memory-logger` spawns immediately. The watermark on the output side absorbs any over-firing — if a buffer-trip arrives on a transcript chunk that's all tool noise, `memory-logger` reads it, decides nothing is worth logging, advances the watermark, and exits.
|
|
55
|
+
|
|
56
|
+
## Migration notes (from before the plugin existed)
|
|
57
|
+
|
|
58
|
+
- `memory.idleMs` and `memory.dreaming.schedule` already existed in core's `typeclaw.json` schema. They moved into this plugin's `configSchema` verbatim. Existing agents continue to work with no config change.
|
|
59
|
+
- `memory.dreaming.schedule` was previously live-reloadable. It is now **restart-required** because plugin config is read once at boot. To change the schedule, edit `typeclaw.json` and run `typeclaw restart`.
|
|
60
|
+
- The cron job ID changed from `__internal_dreaming` to `__plugin_memory_dreaming`. Anything that referenced the old ID (custom dashboards, scripts) needs updating.
|
|
61
|
+
|
|
62
|
+
## Tests
|
|
63
|
+
|
|
64
|
+
- `index.test.ts` — composition tests (config schema, hook wiring, debounce semantics, MEMORY.md auto-create).
|
|
65
|
+
- `memory-logger.test.ts` — system prompt invariants, watermark handling.
|
|
66
|
+
- `dreaming.test.ts` — orchestration, watermark advancement, git snapshot (including muscle-memory skill files), system prompt + tool-surface invariants.
|
|
67
|
+
- `dreaming-state.test.ts` — fail-open semantics on malformed state.
|
|
68
|
+
- `watermark.test.ts` — marker parsing.
|
|
69
|
+
- `append-tool.test.ts` — append-only semantics.
|
|
70
|
+
- `src/bundled-plugins/guard/policies/skill-authoring.test.ts` — runtime skill authoring guard: path sandboxing, name validation, YAML frontmatter, and write/edit final-content validation.
|
|
71
|
+
- `load-memory.test.ts` — memory section rendering, undreamed-tail filtering, watermark stripping.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { appendFile, mkdir, open, readFile, stat } from 'node:fs/promises'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
|
|
6
|
+
import { defineTool } from '@/plugin'
|
|
7
|
+
|
|
8
|
+
import { fragmentContentHash, parseFragments } from './fragment-parser'
|
|
9
|
+
import { detectSecrets } from './secret-detector'
|
|
10
|
+
|
|
11
|
+
const NEWLINE_BYTE = 0x0a
|
|
12
|
+
|
|
13
|
+
export const appendTool = defineTool({
|
|
14
|
+
description:
|
|
15
|
+
'Append content to a file. Creates the file (and any missing parent directories) if needed. Never truncates or overwrites existing content. If the file is non-empty and does not already end in a newline, a single newline is inserted before the appended content so consecutive appends do not run together. Refuses to write content that contains recognizable credential patterns (API keys, tokens, private keys); record the variable name and how it was discovered, never the value. Refuses to append a fragment whose topic+body already exists in the file (case-by-case; topics legitimately repeat across days, but byte-equivalent fragments within the same daily stream are duplicates by design).',
|
|
16
|
+
parameters: z.object({
|
|
17
|
+
path: z.string().describe('Path to the file to append to (relative or absolute).'),
|
|
18
|
+
content: z.string().describe('Content to append, exactly as given.'),
|
|
19
|
+
}),
|
|
20
|
+
async execute({ path, content }) {
|
|
21
|
+
const secrets = detectSecrets(content)
|
|
22
|
+
if (secrets.length > 0) {
|
|
23
|
+
const ruleNames = [...new Set(secrets.map((s) => s.rule))].join(', ')
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Refusing to append: content contains a recognized credential pattern (${ruleNames}). ` +
|
|
26
|
+
`Memory fragments must never quote secret values verbatim. Record the env var name and how it ` +
|
|
27
|
+
`was discovered, not the value itself.`,
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
const incomingFragments = parseFragments(content)
|
|
31
|
+
if (incomingFragments.length > 0) {
|
|
32
|
+
const existingHashes = await readExistingFragmentHashes(path)
|
|
33
|
+
const duplicates = incomingFragments.filter((f) => existingHashes.has(fragmentContentHash(f)))
|
|
34
|
+
if (duplicates.length > 0) {
|
|
35
|
+
const topics = duplicates.map((d) => `"${d.topic}"`).join(', ')
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Refusing to append: ${duplicates.length} fragment${duplicates.length === 1 ? '' : 's'} (${topics}) ` +
|
|
38
|
+
`already exist in ${path} with byte-equivalent content. The dreaming subagent will see the existing ` +
|
|
39
|
+
`fragment; do not write it again. If the new occurrence is genuinely informative (e.g. a recurrence ` +
|
|
40
|
+
`that establishes a pattern), write a fragment that says so explicitly rather than restating the ` +
|
|
41
|
+
`original.`,
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
await mkdir(dirname(path), { recursive: true })
|
|
46
|
+
const prefix = (await needsLeadingNewline(path)) ? '\n' : ''
|
|
47
|
+
await appendFile(path, prefix + content, 'utf-8')
|
|
48
|
+
const bytesAppended = prefix.length + content.length
|
|
49
|
+
return {
|
|
50
|
+
content: [{ type: 'text' as const, text: `Appended ${bytesAppended} bytes to ${path}` }],
|
|
51
|
+
details: { path, bytesAppended, leadingNewlineInserted: prefix.length > 0 },
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
async function readExistingFragmentHashes(path: string): Promise<Set<string>> {
|
|
57
|
+
let content: string
|
|
58
|
+
try {
|
|
59
|
+
content = await readFile(path, 'utf8')
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return new Set()
|
|
62
|
+
throw err
|
|
63
|
+
}
|
|
64
|
+
return new Set(parseFragments(content).map((f) => fragmentContentHash(f)))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function needsLeadingNewline(path: string): Promise<boolean> {
|
|
68
|
+
let info: Awaited<ReturnType<typeof stat>>
|
|
69
|
+
try {
|
|
70
|
+
info = await stat(path)
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false
|
|
73
|
+
throw err
|
|
74
|
+
}
|
|
75
|
+
if (info.size === 0) return false
|
|
76
|
+
const fh = await open(path, 'r')
|
|
77
|
+
try {
|
|
78
|
+
const buf = Buffer.alloc(1)
|
|
79
|
+
await fh.read(buf, 0, 1, info.size - 1)
|
|
80
|
+
return buf[0] !== NEWLINE_BYTE
|
|
81
|
+
} finally {
|
|
82
|
+
await fh.close()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export const DREAMING_STATE_FILE = 'memory/.dreaming-state.json'
|
|
6
|
+
|
|
7
|
+
const VERSION = 1
|
|
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.
|
|
13
|
+
//
|
|
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.
|
|
17
|
+
export type DreamingState = {
|
|
18
|
+
version: number
|
|
19
|
+
dreamedThrough: Record<string, DreamedDay>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type DreamedDay = {
|
|
23
|
+
lines: number
|
|
24
|
+
ts: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function emptyState(): DreamingState {
|
|
28
|
+
return { version: VERSION, dreamedThrough: {} }
|
|
29
|
+
}
|
|
30
|
+
|
|
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
|
+
export async function loadDreamingState(agentDir: string): Promise<DreamingState> {
|
|
36
|
+
const path = join(agentDir, DREAMING_STATE_FILE)
|
|
37
|
+
if (!existsSync(path)) return emptyState()
|
|
38
|
+
|
|
39
|
+
let raw: string
|
|
40
|
+
try {
|
|
41
|
+
raw = await readFile(path, 'utf8')
|
|
42
|
+
} catch {
|
|
43
|
+
return emptyState()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let parsed: unknown
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(raw)
|
|
49
|
+
} catch {
|
|
50
|
+
return emptyState()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!isDreamingState(parsed)) return emptyState()
|
|
54
|
+
return parsed
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function saveDreamingState(agentDir: string, state: DreamingState): Promise<void> {
|
|
58
|
+
const path = join(agentDir, DREAMING_STATE_FILE)
|
|
59
|
+
await mkdir(dirname(path), { recursive: true })
|
|
60
|
+
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getDreamedLines(state: DreamingState, date: string): number {
|
|
64
|
+
return state.dreamedThrough[date]?.lines ?? 0
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function setDreamedLines(state: DreamingState, date: string, lines: number, ts: string): DreamingState {
|
|
68
|
+
return {
|
|
69
|
+
version: state.version,
|
|
70
|
+
dreamedThrough: { ...state.dreamedThrough, [date]: { lines, ts } },
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isDreamingState(value: unknown): value is DreamingState {
|
|
75
|
+
if (typeof value !== 'object' || value === null) return false
|
|
76
|
+
const v = value as Record<string, unknown>
|
|
77
|
+
if (v.version !== VERSION) return false
|
|
78
|
+
if (typeof v.dreamedThrough !== 'object' || v.dreamedThrough === null) return false
|
|
79
|
+
for (const [, entry] of Object.entries(v.dreamedThrough as Record<string, unknown>)) {
|
|
80
|
+
if (typeof entry !== 'object' || entry === null) return false
|
|
81
|
+
const e = entry as Record<string, unknown>
|
|
82
|
+
if (typeof e.lines !== 'number' || e.lines < 0) return false
|
|
83
|
+
if (typeof e.ts !== 'string') return false
|
|
84
|
+
}
|
|
85
|
+
return true
|
|
86
|
+
}
|