typeclaw 0.1.6 → 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 +1 -1
- package/src/agent/index.ts +24 -3
- package/src/agent/system-prompt.ts +17 -0
- 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/cli/model.ts +51 -19
- package/src/cli/provider.ts +38 -24
- package/src/config/models-mutation.ts +34 -6
- package/src/init/index.ts +8 -0
- package/src/run/channel-session-factory.ts +2 -0
- package/src/run/index.ts +6 -0
- package/src/server/index.ts +3 -0
- package/src/skills/typeclaw-memory/SKILL.md +15 -15
package/package.json
CHANGED
package/src/agent/index.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
'
|
|
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
|