-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathpaths.ts
More file actions
199 lines (186 loc) · 6.5 KB
/
Copy pathpaths.ts
File metadata and controls
199 lines (186 loc) · 6.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
/**
* Loop filesystem paths.
*
* Production location: ~/.opencontext/loop/
* - signals.jsonl append-only signal log
* - decisions.json { pending, done, dismissed } buckets
* - status.json last-tick summary (for at-a-glance status)
* - brief.json most-recent brief snapshot
* - wrap.json most-recent wrap snapshot
* - connectors.json cached connector status (60s TTL)
* - config.json LoopPreferences
* - mutes.json key-scoped skip rules (dismiss → "don't show this kind again")
* - migrated.json marker written after legacy data migration
*
* Legacy location (read once on first boot): skills/opencontext-loop/data/
* The `migrate()` function copies the legacy signals.jsonl + decisions.json
* into the new location, never deleting the originals.
*/
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { getOpenContextPath } from "@melandlabs/env-config";
export const LOOP_HOME = getOpenContextPath("loop");
export const LOOP_PATHS = {
home: LOOP_HOME,
signals: join(LOOP_HOME, "signals.jsonl"),
decisions: join(LOOP_HOME, "decisions.json"),
status: join(LOOP_HOME, "status.json"),
brief: join(LOOP_HOME, "brief.json"),
wrap: join(LOOP_HOME, "wrap.json"),
connectors: join(LOOP_HOME, "connectors.json"),
config: join(LOOP_HOME, "config.json"),
mutes: join(LOOP_HOME, "mutes.json"),
migrated: join(LOOP_HOME, "migrated.json"),
log: join(LOOP_HOME, "loop.log"),
inbox: join(LOOP_HOME, "inbox"),
/** Per-connector lastSyncAt — read by watcher, written after each pass. */
syncState: join(LOOP_HOME, "sync-state.json"),
/**
* User-defined decision types (label / icon / actionKind). Per-user
* extension to the closed `DecisionType` union — see `lib/loop/custom-types.ts`.
*/
customTypes: join(LOOP_HOME, "custom-types.json"),
/**
* User-defined signal channels (Composio-backed pullers). Per-user
* extension to the FALLBACK_CONNECTORS list — see `lib/loop/custom-channels.ts`.
*/
customChannels: join(LOOP_HOME, "custom-channels.json"),
/**
* User-defined deterministic classifier rules. Per-user extension to the
* hard-coded rules in `classify.ts` and the agentic prompt's §5
* classifier list — see `lib/loop/classifier-rules.ts`. Rules take
* priority over the LLM's natural-language classification (server-side
* enforcement after the agentic tick).
*/
classifierRules: join(LOOP_HOME, "classifier-rules.json"),
/**
* Activation state machine cache (Issue #351). Atomically written by
* `lib/loop/activation.ts` so the Tauri pet watcher can poll progress
* (`uninitialized → setup_pending → runtime_ready → source_pending →
* check_pending → decision_pending → activated`) without an HTTP
* round-trip back into the Next.js server.
*/
activationState: join(LOOP_HOME, "activation_state.json"),
/**
* SP-4 — daily OS-notification counter. Persisted separately from
* `config.json` so the budget write path doesn't race with
* `writePreferences`. Holds `{ day, count }` keyed by the
* user-local YYYY-MM-DD string (matches `briefTimeToCron` so the
* budget rolls over at the same wall-clock midnight the briefs do).
* Missing file → treated as `{ day: "", count: 0 }`.
*/
attention: join(LOOP_HOME, "attention.json"),
} as const;
export function ensureDirs(): void {
mkdirSync(LOOP_PATHS.home, { recursive: true });
mkdirSync(join(LOOP_PATHS.inbox, ".processed"), { recursive: true });
mkdirSync(join(LOOP_PATHS.inbox, ".failed"), { recursive: true });
}
interface MigrationSource {
signals: string;
decisions: string;
}
function legacySourceCandidates(): MigrationSource[] {
const out: MigrationSource[] = [];
// Walk up the current working dir looking for skills/opencontext-loop/data.
// Covers `cd apps/web && node ...` and `cd /path/to/opencontext && node ...`.
const cwd = process.cwd();
const probes = [
cwd,
resolve(cwd, ".."),
resolve(cwd, "../.."),
resolve(cwd, "../../.."),
resolve(cwd, "../../../.."),
];
for (const dir of probes) {
const dataDir = join(dir, "skills", "opencontext-loop", "data");
if (existsSync(join(dataDir, "decisions.json"))) {
out.push({
signals: join(dataDir, "signals.jsonl"),
decisions: join(dataDir, "decisions.json"),
});
}
}
return out;
}
interface MigratedMarker {
migratedAt: string;
sources: { signals: string; decisions: string }[];
signalsCopied: number;
decisionsCopied: number;
}
/**
* Soft-migrate legacy skill data into the new loop home. Idempotent — once
* the marker file is written, subsequent calls no-op. Never deletes legacy
* files; users who want to reclaim space can rm -rf them after verifying.
*/
export function migrate(): MigratedMarker | null {
ensureDirs();
if (existsSync(LOOP_PATHS.migrated)) {
try {
return JSON.parse(readFileSync(LOOP_PATHS.migrated, "utf8")) as MigratedMarker;
} catch {
// corrupted marker — re-migrate
}
}
const sources = legacySourceCandidates();
if (sources.length === 0) return null;
let signalsCopied = 0;
let decisionsCopied = 0;
for (const src of sources) {
if (existsSync(src.signals)) {
const target = LOOP_PATHS.signals;
if (!existsSync(target)) {
try {
copyFileSync(src.signals, target);
signalsCopied = countLines(src.signals);
} catch (_e) {}
}
}
if (existsSync(src.decisions)) {
const target = LOOP_PATHS.decisions;
if (!existsSync(target)) {
try {
copyFileSync(src.decisions, target);
decisionsCopied = countDecisionLines(src.decisions);
} catch (_e) {}
}
}
}
const marker: MigratedMarker = {
migratedAt: new Date().toISOString(),
sources: sources.map((s) => ({
signals: s.signals,
decisions: s.decisions,
})),
signalsCopied,
decisionsCopied,
};
try {
writeFileSync(LOOP_PATHS.migrated, JSON.stringify(marker, null, 2));
} catch (_e) {}
return marker;
}
function countLines(p: string): number {
try {
return readFileSync(p, "utf8").split("\n").filter(Boolean).length;
} catch {
return 0;
}
}
function countDecisionLines(p: string): number {
try {
const d = JSON.parse(readFileSync(p, "utf8"));
return (
(Array.isArray(d.pending) ? d.pending.length : 0) +
(Array.isArray(d.done) ? d.done.length : 0) +
(Array.isArray(d.dismissed) ? d.dismissed.length : 0)
);
} catch {
return 0;
}
}
/** Resolve a directory and ensure it exists. Used by adjacent helpers. */
export function ensureParent(p: string): void {
mkdirSync(dirname(p), { recursive: true });
}