@geraldmaron/construct 1.0.6 → 1.0.8
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/README.md +39 -34
- package/bin/construct +1403 -96
- package/commands/build/feature.md +1 -1
- package/commands/build/fix.md +1 -1
- package/commands/design/access.md +1 -1
- package/commands/design/flow.md +1 -1
- package/commands/design/ui.md +1 -1
- package/commands/measure/experiment.md +1 -1
- package/commands/measure/metrics.md +1 -1
- package/commands/measure/results.md +1 -1
- package/commands/plan/api.md +1 -1
- package/commands/plan/challenge.md +1 -1
- package/commands/plan/decide.md +1 -1
- package/commands/plan/feature.md +1 -1
- package/commands/plan/requirements.md +1 -1
- package/commands/remember/context.md +1 -1
- package/commands/remember/handoff.md +1 -1
- package/commands/remember/runbook.md +1 -1
- package/commands/review/code.md +1 -1
- package/commands/review/quality.md +1 -1
- package/commands/review/security.md +1 -1
- package/commands/ship/ready.md +1 -1
- package/commands/ship/release.md +1 -1
- package/commands/ship/status.md +1 -1
- package/commands/understand/docs.md +1 -1
- package/commands/understand/this.md +1 -1
- package/commands/understand/why.md +1 -1
- package/commands/work/clean.md +1 -1
- package/commands/work/drive.md +1 -1
- package/commands/work/optimize-prompts.md +2 -2
- package/commands/work/parallel-review.md +1 -1
- package/db/schema/007_tags.sql +30 -0
- package/db/schema/008_skill_usage.sql +24 -0
- package/db/schema/009_scheduler.sql +14 -0
- package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
- package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
- package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
- package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
- package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
- package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
- package/examples/seed-observations/decisions.md +1 -1
- package/lib/audit-skills.mjs +3 -3
- package/lib/auto-docs.mjs +8 -8
- package/lib/boundary.mjs +126 -0
- package/lib/cache-strategy-google.js +26 -31
- package/lib/cli-commands.mjs +276 -122
- package/lib/comment-lint.mjs +139 -1
- package/lib/completions.mjs +1 -1
- package/lib/config/schema.mjs +15 -1
- package/lib/contracts/validate.mjs +371 -0
- package/lib/daemons/contract.mjs +210 -0
- package/lib/distill.mjs +22 -4
- package/lib/doc-stamp.mjs +48 -0
- package/lib/docs-verify.mjs +59 -6
- package/lib/doctor/cli.mjs +16 -1
- package/lib/doctor/index.mjs +3 -1
- package/lib/doctor/watchers/consistency.mjs +360 -0
- package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
- package/lib/document-extract.mjs +420 -17
- package/lib/document-ingest.mjs +2 -0
- package/lib/embed/cli.mjs +124 -3
- package/lib/embed/daemon.mjs +43 -4
- package/lib/embed/docs-lifecycle.mjs +1 -1
- package/lib/embed/inbox.mjs +50 -3
- package/lib/embed/role-framing.mjs +3 -3
- package/lib/embed/scheduler.mjs +33 -5
- package/lib/env-config.mjs +9 -3
- package/lib/evaluator-optimizer.mjs +2 -3
- package/lib/extractors/calendar.mjs +173 -0
- package/lib/extractors/shared/drop-info.mjs +22 -0
- package/lib/extractors/transcript.mjs +0 -0
- package/lib/gates-audit.mjs +8 -2
- package/lib/git-hooks/prepare-commit-msg +1 -1
- package/lib/headhunt.mjs +2 -2
- package/lib/hooks/agent-tracker.mjs +6 -11
- package/lib/hooks/comment-lint.mjs +16 -0
- package/lib/hooks/guard-bash.mjs +35 -14
- package/lib/hooks/mcp-audit.mjs +14 -1
- package/lib/hooks/pre-compact.mjs +92 -5
- package/lib/hooks/pre-push-gate.mjs +41 -8
- package/lib/hooks/proactive-activation.mjs +0 -14
- package/lib/hooks/registry-sync.mjs +2 -2
- package/lib/hooks/rule-verifier.mjs +217 -0
- package/lib/hooks/session-optimize.mjs +2 -1
- package/lib/hooks/stop-notify.mjs +7 -6
- package/lib/host-capabilities.mjs +10 -1
- package/lib/hygiene/scan.mjs +141 -0
- package/lib/init-unified.mjs +73 -65
- package/lib/install/stage-project.mjs +4 -4
- package/lib/intake/classify.mjs +309 -58
- package/lib/intake/daemon.mjs +121 -0
- package/lib/intake/filesystem-queue.mjs +31 -6
- package/lib/intake/intake-config.mjs +2 -1
- package/lib/intake/postgres-queue.mjs +45 -3
- package/lib/intake/prepare.mjs +3 -1
- package/lib/intake/quarantine.mjs +205 -0
- package/lib/intake/session-prelude.mjs +7 -1
- package/lib/intake/traceability.mjs +90 -0
- package/lib/knowledge/postgres-search.mjs +132 -0
- package/lib/knowledge/research-store.mjs +2 -0
- package/lib/knowledge/search.mjs +14 -4
- package/lib/maintenance/cleanup.mjs +315 -0
- package/lib/mcp/memory-bridge.mjs +276 -0
- package/lib/mcp/server.mjs +11 -3
- package/lib/mcp/tools/skills.mjs +32 -11
- package/lib/mcp/tools/workflow.mjs +25 -0
- package/lib/mcp-catalog.json +12 -8
- package/lib/mcp-platform-config.mjs +16 -8
- package/lib/migrations/index.mjs +106 -0
- package/lib/migrations/v1-baseline.mjs +33 -0
- package/lib/model-registry.mjs +1 -1
- package/lib/observation-store.mjs +9 -4
- package/lib/opencode-runtime-plugin.mjs +1 -1
- package/lib/orchestration-policy.mjs +4 -4
- package/lib/outcomes/record.mjs +2 -0
- package/lib/overrides/resolver.mjs +3 -3
- package/lib/parity.mjs +4 -4
- package/lib/policy/engine.mjs +2 -2
- package/lib/profiles/lifecycle.mjs +1 -1
- package/lib/profiles/rebrand.mjs +46 -0
- package/lib/project-init-shared.mjs +12 -0
- package/lib/prompt-metadata.mjs +4 -4
- package/lib/provider-capabilities.js +20 -7
- package/lib/providers/auth-manager.mjs +58 -17
- package/lib/providers/circuit-breaker.mjs +14 -0
- package/lib/providers/contract.mjs +67 -3
- package/lib/providers/creds.mjs +219 -0
- package/lib/providers/scaffold.mjs +107 -0
- package/lib/reflect.mjs +49 -12
- package/lib/role-preload.mjs +5 -0
- package/lib/roles/catalog.mjs +1 -1
- package/lib/roles/manifest.mjs +2 -2
- package/lib/scheduler/index.mjs +112 -0
- package/lib/scheduler/solo.mjs +183 -0
- package/lib/server/index.mjs +29 -29
- package/lib/server/insights.mjs +14 -12
- package/lib/server/langfuse-login.mjs +58 -0
- package/lib/server/static/assets/index-ab25c707.js +1 -1
- package/lib/session-store.mjs +6 -4
- package/lib/setup.mjs +14 -3
- package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
- package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
- package/lib/{agents → specialists}/postconditions.mjs +3 -3
- package/lib/{agents → specialists}/schema.mjs +6 -6
- package/lib/status.mjs +13 -11
- package/lib/storage/backup.mjs +2 -2
- package/lib/tags/lifecycle.mjs +278 -0
- package/lib/tags/vocabulary.mjs +140 -0
- package/lib/telemetry/client.mjs +5 -1
- package/lib/telemetry/otel-tracer.mjs +184 -0
- package/lib/telemetry/skill-calls.mjs +73 -12
- package/lib/uninstall/uninstall.mjs +1 -1
- package/lib/update.mjs +1 -1
- package/lib/validator.mjs +57 -56
- package/lib/version.mjs +51 -0
- package/lib/worker/trace.mjs +5 -1
- package/lib/workflows/instantiate.mjs +320 -0
- package/package.json +16 -3
- package/personas/construct.md +5 -3
- package/platforms/claude/CLAUDE.md +1 -1
- package/rules/common/development-workflow.md +2 -1
- package/rules/common/no-fabrication.md +69 -0
- package/rules/common/review-before-change.md +58 -0
- package/scripts/{sync-agents.mjs → sync-specialists.mjs} +171 -68
- package/skills/ai/prompt-optimizer.md +3 -3
- package/skills/exploration/dependency-graph-reading.md +98 -0
- package/skills/exploration/tracer-bullet-method.md +71 -0
- package/skills/exploration/unknown-codebase-onboarding.md +91 -0
- package/skills/operating/change-management.md +91 -0
- package/skills/operating/incident-response.md +75 -0
- package/skills/operating/oncall-rotation.md +95 -0
- package/skills/operating/orchestration-reference.md +2 -2
- package/skills/strategy/competitive-landscape.md +75 -0
- package/skills/strategy/market-research-methods.md +87 -0
- package/skills/strategy/narrative-arc.md +77 -0
- package/skills/strategy/pricing-positioning.md +94 -0
- package/specialists/contracts.json +1002 -0
- package/specialists/contracts.schema.json +83 -0
- package/specialists/policy-inventory.json +160 -0
- package/{agents → specialists}/prompts/cx-accessibility.md +3 -1
- package/{agents → specialists}/prompts/cx-ai-engineer.md +3 -1
- package/{agents → specialists}/prompts/cx-architect.md +3 -1
- package/{agents → specialists}/prompts/cx-business-strategist.md +3 -1
- package/{agents → specialists}/prompts/cx-data-analyst.md +3 -1
- package/{agents → specialists}/prompts/cx-data-engineer.md +3 -1
- package/{agents → specialists}/prompts/cx-debugger.md +3 -1
- package/{agents → specialists}/prompts/cx-designer.md +3 -1
- package/{agents → specialists}/prompts/cx-devil-advocate.md +3 -1
- package/{agents → specialists}/prompts/cx-docs-keeper.md +4 -2
- package/{agents → specialists}/prompts/cx-engineer.md +4 -2
- package/{agents → specialists}/prompts/cx-evaluator.md +3 -1
- package/{agents → specialists}/prompts/cx-explorer.md +3 -1
- package/{agents → specialists}/prompts/cx-legal-compliance.md +3 -1
- package/{agents → specialists}/prompts/cx-operations.md +3 -1
- package/{agents → specialists}/prompts/cx-orchestrator.md +4 -2
- package/{agents → specialists}/prompts/cx-platform-engineer.md +3 -1
- package/{agents → specialists}/prompts/cx-product-manager.md +3 -1
- package/{agents → specialists}/prompts/cx-qa.md +3 -1
- package/{agents → specialists}/prompts/cx-rd-lead.md +3 -1
- package/{agents → specialists}/prompts/cx-release-manager.md +3 -1
- package/{agents → specialists}/prompts/cx-researcher.md +3 -1
- package/{agents → specialists}/prompts/cx-reviewer.md +4 -2
- package/{agents → specialists}/prompts/cx-security.md +3 -1
- package/{agents → specialists}/prompts/cx-sre.md +4 -2
- package/{agents → specialists}/prompts/cx-test-automation.md +3 -1
- package/{agents → specialists}/prompts/cx-trace-reviewer.md +5 -3
- package/{agents → specialists}/prompts/cx-ux-researcher.md +3 -1
- package/{agents → specialists}/registry.json +651 -418
- package/{agents → specialists}/role-manifests.json +8 -8
- package/templates/docs/construct_guide.md +6 -6
- package/templates/docs/skill-artifact.md +1 -1
- package/templates/provider-scaffold/health.test.mjs +30 -0
- package/templates/provider-scaffold/index.mjs +48 -0
- package/templates/workflows/cross-team-handoff.yml +85 -0
- package/templates/workflows/engineering-onboarding.yml +77 -0
- package/templates/workflows/new-feature.yml +53 -0
- package/agents/contracts.json +0 -387
- /package/{agents → specialists}/teams.json +0 -0
package/lib/embed/cli.mjs
CHANGED
|
@@ -41,6 +41,83 @@ function logPath(homeDir = os.homedir()) {
|
|
|
41
41
|
return path.join(runtimeDir(homeDir), LOG_FILE);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Log rotation
|
|
46
|
+
//
|
|
47
|
+
// The embed daemon writes its stdout/stderr to a single appended file. The
|
|
48
|
+
// file descriptor is opened by the parent process at spawn time; the daemon
|
|
49
|
+
// has no opportunity to reopen mid-flight. Rotation therefore happens at
|
|
50
|
+
// each daemon spawn: if the existing log exceeds the size cap we shift
|
|
51
|
+
// segments (.log → .log.1 → .log.2 → …) and drop the oldest, then the
|
|
52
|
+
// spawn opens a fresh empty file.
|
|
53
|
+
//
|
|
54
|
+
// CONSTRUCT_EMBED_LOG_MAX_MB sets the rotation threshold (default 50 MB).
|
|
55
|
+
// CONSTRUCT_EMBED_LOG_KEEP sets retained segment count (default 3).
|
|
56
|
+
// Worst-case disk footprint is (MAX_MB * (KEEP + 1)).
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
const DEFAULT_MAX_MB = 50;
|
|
60
|
+
const DEFAULT_KEEP = 3;
|
|
61
|
+
const HARD_CAP_MAX_MB = 500; // refuse absurd configured values
|
|
62
|
+
const HARD_CAP_KEEP = 20;
|
|
63
|
+
|
|
64
|
+
function rotationConfig(env = process.env) {
|
|
65
|
+
const maxMbRaw = Number.parseInt(env.CONSTRUCT_EMBED_LOG_MAX_MB ?? '', 10);
|
|
66
|
+
const keepRaw = Number.parseInt(env.CONSTRUCT_EMBED_LOG_KEEP ?? '', 10);
|
|
67
|
+
const maxMb = Number.isFinite(maxMbRaw) && maxMbRaw > 0 ? Math.min(maxMbRaw, HARD_CAP_MAX_MB) : DEFAULT_MAX_MB;
|
|
68
|
+
const keep = Number.isFinite(keepRaw) && keepRaw >= 0 ? Math.min(keepRaw, HARD_CAP_KEEP) : DEFAULT_KEEP;
|
|
69
|
+
return { maxBytes: maxMb * 1024 * 1024, keep };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Rotate the embed daemon log if it exceeds the size cap. Exported for tests.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} log - Absolute path to the live log file
|
|
76
|
+
* @param {object} [env] - Env object (default: process.env)
|
|
77
|
+
* @returns {{ rotated: boolean, sizeBytes: number, droppedSegment?: string }}
|
|
78
|
+
*/
|
|
79
|
+
export function rotateEmbedLogIfNeeded(log, env = process.env) {
|
|
80
|
+
let sizeBytes = 0;
|
|
81
|
+
try {
|
|
82
|
+
sizeBytes = fs.statSync(log).size;
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if (err.code === 'ENOENT') return { rotated: false, sizeBytes: 0 };
|
|
85
|
+
process.stderr.write(`[cli.mjs] rotateEmbedLog stat: ${err.message}\n`);
|
|
86
|
+
return { rotated: false, sizeBytes: 0 };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const { maxBytes, keep } = rotationConfig(env);
|
|
90
|
+
if (sizeBytes <= maxBytes) return { rotated: false, sizeBytes };
|
|
91
|
+
|
|
92
|
+
// Shift segments oldest-first: .{keep} gets dropped, .{n} → .{n+1}, .log → .log.1
|
|
93
|
+
let droppedSegment;
|
|
94
|
+
try {
|
|
95
|
+
const oldest = `${log}.${keep}`;
|
|
96
|
+
if (keep === 0) {
|
|
97
|
+
// Retention of 0 means rotate-and-discard: the existing log is removed,
|
|
98
|
+
// no segment is kept.
|
|
99
|
+
fs.rmSync(log, { force: true });
|
|
100
|
+
droppedSegment = log;
|
|
101
|
+
return { rotated: true, sizeBytes, droppedSegment };
|
|
102
|
+
}
|
|
103
|
+
if (fs.existsSync(oldest)) {
|
|
104
|
+
fs.rmSync(oldest, { force: true });
|
|
105
|
+
droppedSegment = oldest;
|
|
106
|
+
}
|
|
107
|
+
for (let n = keep - 1; n >= 1; n--) {
|
|
108
|
+
const from = `${log}.${n}`;
|
|
109
|
+
const to = `${log}.${n + 1}`;
|
|
110
|
+
if (fs.existsSync(from)) fs.renameSync(from, to);
|
|
111
|
+
}
|
|
112
|
+
fs.renameSync(log, `${log}.1`);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
process.stderr.write(`[cli.mjs] rotateEmbedLog: ${err.message}\n`);
|
|
115
|
+
return { rotated: false, sizeBytes };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { rotated: true, sizeBytes, droppedSegment };
|
|
119
|
+
}
|
|
120
|
+
|
|
44
121
|
// ---------------------------------------------------------------------------
|
|
45
122
|
// State helpers
|
|
46
123
|
// ---------------------------------------------------------------------------
|
|
@@ -140,6 +217,7 @@ export async function autoStartEmbedIfNeeded(env = process.env, rootDir, homeDir
|
|
|
140
217
|
const workerPath = path.join(resolvedRoot, 'lib', 'embed', 'worker.mjs');
|
|
141
218
|
const log = logPath(homeDir);
|
|
142
219
|
fs.mkdirSync(path.dirname(log), { recursive: true });
|
|
220
|
+
rotateEmbedLogIfNeeded(log, env);
|
|
143
221
|
const fd = fs.openSync(log, 'a');
|
|
144
222
|
|
|
145
223
|
const child = spawn(process.execPath, [workerPath], {
|
|
@@ -149,7 +227,18 @@ export async function autoStartEmbedIfNeeded(env = process.env, rootDir, homeDir
|
|
|
149
227
|
});
|
|
150
228
|
child.unref();
|
|
151
229
|
|
|
152
|
-
|
|
230
|
+
let resolvedRootDir = homeDir;
|
|
231
|
+
try {
|
|
232
|
+
const { resolveRootDir } = await import('./daemon.mjs');
|
|
233
|
+
resolvedRootDir = resolveRootDir(env, process.cwd());
|
|
234
|
+
} catch { /* fall back to homeDir */ }
|
|
235
|
+
|
|
236
|
+
writeState({
|
|
237
|
+
pid: child.pid,
|
|
238
|
+
configPath: 'auto',
|
|
239
|
+
startedAt: new Date().toISOString(),
|
|
240
|
+
rootDir: resolvedRootDir,
|
|
241
|
+
}, homeDir);
|
|
153
242
|
return { started: true, pid: child.pid };
|
|
154
243
|
} catch (err) {
|
|
155
244
|
return { started: false, reason: `spawn_failed: ${err.message}` };
|
|
@@ -196,6 +285,7 @@ async function cmdEmbedStart(args, { homeDir = os.homedir(), rootDir, _workerPat
|
|
|
196
285
|
const workerArgs = configPath ? [workerPath, '--config', configPath] : [workerPath];
|
|
197
286
|
const log = logPath(homeDir);
|
|
198
287
|
fs.mkdirSync(path.dirname(log), { recursive: true });
|
|
288
|
+
rotateEmbedLogIfNeeded(log, process.env);
|
|
199
289
|
const fd = fs.openSync(log, 'a');
|
|
200
290
|
|
|
201
291
|
const child = spawn(process.execPath, workerArgs, {
|
|
@@ -206,7 +296,21 @@ async function cmdEmbedStart(args, { homeDir = os.homedir(), rootDir, _workerPat
|
|
|
206
296
|
fs.closeSync(fd);
|
|
207
297
|
child.unref();
|
|
208
298
|
|
|
209
|
-
|
|
299
|
+
// Resolve rootDir using the same precedence the spawned worker will so the
|
|
300
|
+
// operator can see which inbox is being watched via `construct embed status`.
|
|
301
|
+
|
|
302
|
+
let resolvedRootDir = homeDir;
|
|
303
|
+
try {
|
|
304
|
+
const { resolveRootDir } = await import('./daemon.mjs');
|
|
305
|
+
resolvedRootDir = resolveRootDir(process.env, process.cwd());
|
|
306
|
+
} catch { /* fall back to homeDir */ }
|
|
307
|
+
|
|
308
|
+
writeState({
|
|
309
|
+
pid: child.pid,
|
|
310
|
+
configPath: configPath ?? 'auto',
|
|
311
|
+
startedAt: new Date().toISOString(),
|
|
312
|
+
rootDir: resolvedRootDir,
|
|
313
|
+
}, homeDir);
|
|
210
314
|
|
|
211
315
|
// Brief liveness check — detects immediate crashes (e.g. missing module, bad config path)
|
|
212
316
|
await new Promise(r => setTimeout(r, _livenessCheckMs));
|
|
@@ -245,15 +349,32 @@ function cmdEmbedStop(_args, { homeDir = os.homedir() } = {}) {
|
|
|
245
349
|
// Subcommand: status
|
|
246
350
|
// ---------------------------------------------------------------------------
|
|
247
351
|
|
|
248
|
-
function cmdEmbedStatus(_args, { homeDir = os.homedir() } = {}) {
|
|
352
|
+
async function cmdEmbedStatus(_args, { homeDir = os.homedir() } = {}) {
|
|
249
353
|
const state = readRunningState(homeDir);
|
|
250
354
|
if (!state) {
|
|
251
355
|
process.stdout.write('embed daemon: stopped\n');
|
|
252
356
|
return;
|
|
253
357
|
}
|
|
358
|
+
|
|
359
|
+
// Re-resolve rootDir using the same precedence the daemon would so the
|
|
360
|
+
// operator sees which inbox is actually being watched (CX_DATA_DIR override,
|
|
361
|
+
// walked-up project root, or homedir fallback).
|
|
362
|
+
|
|
363
|
+
let rootDir = state.rootDir;
|
|
364
|
+
if (!rootDir) {
|
|
365
|
+
try {
|
|
366
|
+
const { resolveRootDir } = await import('./daemon.mjs');
|
|
367
|
+
rootDir = resolveRootDir(process.env, process.cwd());
|
|
368
|
+
} catch {
|
|
369
|
+
rootDir = homeDir;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
254
373
|
process.stdout.write(`embed daemon: running\n`);
|
|
255
374
|
process.stdout.write(` pid: ${state.pid}\n`);
|
|
256
375
|
process.stdout.write(` config: ${state.configPath}\n`);
|
|
376
|
+
process.stdout.write(` rootDir: ${rootDir}\n`);
|
|
377
|
+
process.stdout.write(` inbox: ${path.join(rootDir, '.cx', 'inbox')}\n`);
|
|
257
378
|
process.stdout.write(` started at: ${state.startedAt}\n`);
|
|
258
379
|
process.stdout.write(` log: ${logPath(homeDir)}\n`);
|
|
259
380
|
}
|
package/lib/embed/daemon.mjs
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import { existsSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
28
28
|
import { homedir } from 'node:os';
|
|
29
|
-
import { join } from 'node:path';
|
|
29
|
+
import { dirname, join } from 'node:path';
|
|
30
30
|
|
|
31
31
|
import { loadEmbedConfig } from './config.mjs';
|
|
32
32
|
import { Scheduler } from './scheduler.mjs';
|
|
@@ -109,8 +109,44 @@ async function resolveLLMKey(varName, env) {
|
|
|
109
109
|
|
|
110
110
|
// ─── Root dir resolution ─────────────────────────────────────────────────────
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
// Cap upward search so a daemon launched from deep in the filesystem cannot
|
|
113
|
+
// fan out to thousands of stat() calls in pathological layouts.
|
|
114
|
+
|
|
115
|
+
const PROJECT_WALKUP_MAX = 10;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Walk up from `startDir` looking for a directory that contains `.cx/context.md`.
|
|
119
|
+
* Returns the project root path, or null if nothing is found within the cap.
|
|
120
|
+
* Exported for unit tests.
|
|
121
|
+
*/
|
|
122
|
+
export function findProjectRoot(startDir, { maxLevels = PROJECT_WALKUP_MAX } = {}) {
|
|
123
|
+
if (!startDir || typeof startDir !== 'string') return null;
|
|
124
|
+
let current = startDir;
|
|
125
|
+
for (let i = 0; i <= maxLevels; i++) {
|
|
126
|
+
try {
|
|
127
|
+
if (existsSync(join(current, '.cx', 'context.md'))) return current;
|
|
128
|
+
} catch { /* skip unreadable dir */ }
|
|
129
|
+
const parent = dirname(current);
|
|
130
|
+
if (!parent || parent === current) return null;
|
|
131
|
+
current = parent;
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Resolve the daemon's rootDir.
|
|
138
|
+
*
|
|
139
|
+
* Precedence:
|
|
140
|
+
* 1. `CX_DATA_DIR` env override (operator authority)
|
|
141
|
+
* 2. Project root discovered by walking up from `cwd` looking for `.cx/context.md`
|
|
142
|
+
* 3. `homedir()` fallback
|
|
143
|
+
*/
|
|
144
|
+
export function resolveRootDir(env = process.env, cwd = process.cwd()) {
|
|
145
|
+
const override = (env.CX_DATA_DIR ?? '').trim();
|
|
146
|
+
if (override) return override;
|
|
147
|
+
const projectRoot = findProjectRoot(cwd);
|
|
148
|
+
if (projectRoot) return projectRoot;
|
|
149
|
+
return homedir();
|
|
114
150
|
}
|
|
115
151
|
|
|
116
152
|
function resolveWorkspaceDir(env = process.env) {
|
|
@@ -708,7 +744,10 @@ this.#scheduler.register(
|
|
|
708
744
|
async () => {
|
|
709
745
|
const result = await this.#inboxWatcher.poll();
|
|
710
746
|
if (result.processed.length) {
|
|
711
|
-
|
|
747
|
+
const { getRebrand } = await import('../profiles/rebrand.mjs');
|
|
748
|
+
const { signalNoun } = getRebrand(this.#rootDir);
|
|
749
|
+
const noun = result.processed.length === 1 ? signalNoun : `${signalNoun}s`;
|
|
750
|
+
process.stderr.write(`[embed] Classified ${result.processed.length} ${noun} from [${result.dirs.join(', ')}]\n`);
|
|
712
751
|
addObservation(this.#rootDir, {
|
|
713
752
|
role: 'construct',
|
|
714
753
|
category: 'insight',
|
|
@@ -64,7 +64,7 @@ export function detectDocGaps(target, opts = {}) {
|
|
|
64
64
|
const gaps = [];
|
|
65
65
|
|
|
66
66
|
if (target.access === 'remote') {
|
|
67
|
-
// Remote-only targets
|
|
67
|
+
// Remote-only targets have no local filesystem to inspect; gap detection requires the local docs tree.
|
|
68
68
|
return gaps;
|
|
69
69
|
}
|
|
70
70
|
|
package/lib/embed/inbox.mjs
CHANGED
|
@@ -21,14 +21,16 @@
|
|
|
21
21
|
* // result: { processed: [...], skipped: number, errors: [...] }
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
25
25
|
import { join, resolve, isAbsolute } from 'node:path';
|
|
26
|
-
import {
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { isExtractableDocumentPath, AUDIO_VIDEO_EXTS } from '../document-extract.mjs';
|
|
27
28
|
import { ingestDocuments } from '../document-ingest.mjs';
|
|
28
29
|
import { addObservation } from '../observation-store.mjs';
|
|
29
30
|
import { inferKnowledgeTarget, knowledgeDirForCategory } from '../knowledge/layout.mjs';
|
|
30
31
|
import { docLaneDir, suggestDocsLaneForFile } from '../docs-routing.mjs';
|
|
31
32
|
import { shouldCreateCx } from '../project-detection.mjs';
|
|
33
|
+
import { ensureCxDir } from '../project-init-shared.mjs';
|
|
32
34
|
import { prepareIntakeForIngestedFile } from '../intake/prepare.mjs';
|
|
33
35
|
import { loadIntakeConfig, INTAKE_DEFAULT_MAX_DEPTH } from '../intake/intake-config.mjs';
|
|
34
36
|
|
|
@@ -38,6 +40,37 @@ const DOCS_INTAKE_SUBDIR = 'docs/intake';
|
|
|
38
40
|
const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB hard cap
|
|
39
41
|
const STALE_STATE_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
40
42
|
|
|
43
|
+
// ─── Extraction-warning helpers ───────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
function extractionWarningsLog() {
|
|
46
|
+
return join(homedir(), '.cx', 'intake', 'extraction-warnings.jsonl');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function needsAsrDir() {
|
|
50
|
+
return join(homedir(), '.cx', 'intake', 'needs-asr');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeExtractionWarning({ sourcePath, droppedInfo }) {
|
|
54
|
+
if (!droppedInfo?.length) return;
|
|
55
|
+
const logPath = extractionWarningsLog();
|
|
56
|
+
try {
|
|
57
|
+
mkdirSync(join(homedir(), '.cx', 'intake'), { recursive: true });
|
|
58
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), sourcePath, droppedInfo }) + '\n';
|
|
59
|
+
appendFileSync(logPath, line);
|
|
60
|
+
} catch { /* non-fatal */ }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeAsrPacket({ sourcePath, extension, errorMessage }) {
|
|
64
|
+
try {
|
|
65
|
+
const dir = needsAsrDir();
|
|
66
|
+
mkdirSync(dir, { recursive: true });
|
|
67
|
+
const id = `asr-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
68
|
+
const packet = { id, ts: new Date().toISOString(), sourcePath, extension, error: errorMessage };
|
|
69
|
+
writeFileSync(join(dir, `${id}.json`), JSON.stringify(packet, null, 2));
|
|
70
|
+
return id;
|
|
71
|
+
} catch { return null; }
|
|
72
|
+
}
|
|
73
|
+
|
|
41
74
|
function nextAvailablePath(targetPath) {
|
|
42
75
|
if (!existsSync(targetPath)) return targetPath;
|
|
43
76
|
const dot = targetPath.lastIndexOf('.');
|
|
@@ -90,6 +123,7 @@ function writeState(rootDir, state) {
|
|
|
90
123
|
|
|
91
124
|
// Only create .cx directories if this is an initialized project
|
|
92
125
|
if (shouldCreateCx(rootDir)) {
|
|
126
|
+
ensureCxDir(rootDir);
|
|
93
127
|
mkdirSync(join(rootDir, '.cx', 'runtime'), { recursive: true });
|
|
94
128
|
writeFileSync(p, JSON.stringify(state, null, 2) + '\n');
|
|
95
129
|
}
|
|
@@ -304,6 +338,10 @@ export class InboxWatcher {
|
|
|
304
338
|
if (contentIdx !== -1) extractedText = md.slice(contentIdx + 20).trim();
|
|
305
339
|
} catch (err) { process.stderr.write('[inbox.mjs] read-extracted-text: ' + (err?.message ?? String(err)) + '\n'); }
|
|
306
340
|
|
|
341
|
+
// Write extraction warnings JSONL for any dropped structured content.
|
|
342
|
+
const droppedInfo = fileResult.droppedInfo ?? [];
|
|
343
|
+
writeExtractionWarning({ sourcePath: candidate.path, droppedInfo });
|
|
344
|
+
|
|
307
345
|
recordInboxObservation(this.#rootDir, {
|
|
308
346
|
sourcePath: candidate.path,
|
|
309
347
|
outputPath: fileResult.outputPath,
|
|
@@ -331,6 +369,7 @@ export class InboxWatcher {
|
|
|
331
369
|
outputPath: fileResult.outputPath,
|
|
332
370
|
characters: fileResult.characters,
|
|
333
371
|
knowledgeSubdir,
|
|
372
|
+
droppedInfo,
|
|
334
373
|
},
|
|
335
374
|
env: this.#env,
|
|
336
375
|
});
|
|
@@ -345,7 +384,15 @@ export class InboxWatcher {
|
|
|
345
384
|
processed.push({ path: candidate.path, outputPath: fileResult.outputPath, docsPath, characters: fileResult.characters, knowledgeSubdir, intakeId });
|
|
346
385
|
}
|
|
347
386
|
} catch (err) {
|
|
348
|
-
|
|
387
|
+
// Audio/video files cannot be text-extracted without ASR. Route to
|
|
388
|
+
// the needs-asr queue so the signal is preserved for later processing.
|
|
389
|
+
if (err.code === 'ASR_REQUIRED') {
|
|
390
|
+
const asrId = writeAsrPacket({ sourcePath: candidate.path, extension: err.extension, errorMessage: err.message });
|
|
391
|
+
state[key] = { mtimeMs: candidate.mtimeMs, processedAt: new Date().toISOString(), needsAsr: true, asrId };
|
|
392
|
+
processed.push({ path: candidate.path, needsAsr: true, asrId });
|
|
393
|
+
} else {
|
|
394
|
+
errors.push({ path: candidate.path, error: err.message });
|
|
395
|
+
}
|
|
349
396
|
}
|
|
350
397
|
}
|
|
351
398
|
}
|
|
@@ -12,7 +12,7 @@ import { join, dirname } from 'node:path';
|
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
|
|
14
14
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
-
const REGISTRY_PATH = join(__dirname, '..', '..', '
|
|
15
|
+
const REGISTRY_PATH = join(__dirname, '..', '..', 'specialists', 'registry.json');
|
|
16
16
|
|
|
17
17
|
let _registryCache = null;
|
|
18
18
|
|
|
@@ -22,7 +22,7 @@ function loadRegistry() {
|
|
|
22
22
|
_registryCache = JSON.parse(readFileSync(REGISTRY_PATH, 'utf8'));
|
|
23
23
|
} catch (err) {
|
|
24
24
|
process.stderr.write('[role-framing.mjs] loadRegistry: ' + (err?.message ?? String(err)) + '\n');
|
|
25
|
-
_registryCache = {
|
|
25
|
+
_registryCache = { specialists: [] };
|
|
26
26
|
}
|
|
27
27
|
return _registryCache;
|
|
28
28
|
}
|
|
@@ -35,7 +35,7 @@ function loadRegistry() {
|
|
|
35
35
|
export function getOrientation(roleName) {
|
|
36
36
|
if (!roleName) return null;
|
|
37
37
|
const registry = loadRegistry();
|
|
38
|
-
const agent = (registry.
|
|
38
|
+
const agent = (registry.specialists ?? []).find((a) => a.name === roleName);
|
|
39
39
|
return agent?.embedOrientation ?? null;
|
|
40
40
|
}
|
|
41
41
|
|
package/lib/embed/scheduler.mjs
CHANGED
|
@@ -4,24 +4,30 @@
|
|
|
4
4
|
* Lightweight alternative to a full cron library — runs tasks at fixed
|
|
5
5
|
* intervals, tracks last-run times, and supports graceful shutdown.
|
|
6
6
|
* Zero external deps.
|
|
7
|
+
*
|
|
8
|
+
* One-shot startup jobs MUST set `repeat: false`. With `repeat: true`
|
|
9
|
+
* (the default) and `intervalMs: 0`, the timer fires on every event-loop
|
|
10
|
+
* tick — the bug that filled embed-daemon.log to 34 GB before this option
|
|
11
|
+
* was added.
|
|
7
12
|
*/
|
|
8
13
|
|
|
9
14
|
export class Scheduler {
|
|
10
|
-
#tasks = new Map(); // id → { fn, intervalMs, lastRun, timer, label }
|
|
15
|
+
#tasks = new Map(); // id → { fn, intervalMs, lastRun, timer, label, repeat }
|
|
11
16
|
#running = false;
|
|
12
17
|
|
|
13
18
|
/**
|
|
14
19
|
* Register a task. Returns the task id.
|
|
15
20
|
* @param {string} label - Human-readable name for logging
|
|
16
|
-
* @param {number} intervalMs - How often to run (ms)
|
|
21
|
+
* @param {number} intervalMs - How often to run (ms); ignored when repeat is false
|
|
17
22
|
* @param {Function} fn - Async function to call; receives { label, lastRun }
|
|
18
23
|
* @param {object} [opts]
|
|
19
24
|
* @param {boolean} [opts.runImmediately=false] - Run once before first interval fires
|
|
20
25
|
* @param {boolean} [opts.unref=false] - Call timer.unref() so this task does not keep the event loop alive (use in tests)
|
|
26
|
+
* @param {boolean} [opts.repeat=true] - When false, the task is one-shot: it runs once (immediately if runImmediately, otherwise after intervalMs) and never re-arms. Required for startup jobs that pass intervalMs: 0.
|
|
21
27
|
*/
|
|
22
|
-
register(label, intervalMs, fn, { runImmediately = false, unref = false } = {}) {
|
|
28
|
+
register(label, intervalMs, fn, { runImmediately = false, unref = false, repeat = true } = {}) {
|
|
23
29
|
const id = `${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
24
|
-
const task = { label, intervalMs, fn, lastRun: null, timer: null, runImmediately, unref };
|
|
30
|
+
const task = { label, intervalMs, fn, lastRun: null, timer: null, runImmediately, unref, repeat };
|
|
25
31
|
this.#tasks.set(id, task);
|
|
26
32
|
|
|
27
33
|
if (this.#running) {
|
|
@@ -50,6 +56,7 @@ export class Scheduler {
|
|
|
50
56
|
for (const task of this.#tasks.values()) {
|
|
51
57
|
if (task.timer) {
|
|
52
58
|
clearInterval(task.timer);
|
|
59
|
+
clearTimeout(task.timer);
|
|
53
60
|
task.timer = null;
|
|
54
61
|
}
|
|
55
62
|
}
|
|
@@ -60,7 +67,10 @@ export class Scheduler {
|
|
|
60
67
|
*/
|
|
61
68
|
unregister(id) {
|
|
62
69
|
const task = this.#tasks.get(id);
|
|
63
|
-
if (task?.timer)
|
|
70
|
+
if (task?.timer) {
|
|
71
|
+
clearInterval(task.timer);
|
|
72
|
+
clearTimeout(task.timer);
|
|
73
|
+
}
|
|
64
74
|
this.#tasks.delete(id);
|
|
65
75
|
}
|
|
66
76
|
|
|
@@ -74,6 +84,7 @@ export class Scheduler {
|
|
|
74
84
|
intervalMs: t.intervalMs,
|
|
75
85
|
lastRun: t.lastRun,
|
|
76
86
|
active: Boolean(t.timer),
|
|
87
|
+
repeat: t.repeat,
|
|
77
88
|
}));
|
|
78
89
|
}
|
|
79
90
|
|
|
@@ -83,6 +94,23 @@ export class Scheduler {
|
|
|
83
94
|
process.stderr.write(`[scheduler] ${task.label} error: ${err.message}\n`);
|
|
84
95
|
});
|
|
85
96
|
}
|
|
97
|
+
|
|
98
|
+
// One-shot jobs do not re-arm. When runImmediately is true, the task has
|
|
99
|
+
// already executed and no further scheduling is needed. When false, a
|
|
100
|
+
// single setTimeout fires once at intervalMs.
|
|
101
|
+
if (!task.repeat) {
|
|
102
|
+
if (!runImmediately) {
|
|
103
|
+
task.timer = setTimeout(() => {
|
|
104
|
+
task.timer = null;
|
|
105
|
+
this.#runTask(task).catch((err) => {
|
|
106
|
+
process.stderr.write(`[scheduler] ${task.label} error: ${err.message}\n`);
|
|
107
|
+
});
|
|
108
|
+
}, task.intervalMs);
|
|
109
|
+
if (task.unref && task.timer.unref) task.timer.unref();
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
86
114
|
task.timer = setInterval(() => {
|
|
87
115
|
this.#runTask(task).catch((err) => {
|
|
88
116
|
process.stderr.write(`[scheduler] ${task.label} error: ${err.message}\n`);
|
package/lib/env-config.mjs
CHANGED
|
@@ -97,11 +97,17 @@ export function loadConstructEnv({ rootDir, homeDir = os.homedir(), env = proces
|
|
|
97
97
|
for (const key of Object.keys(env)) {
|
|
98
98
|
if (ENV_SHADOW_IGNORE.has(key)) continue;
|
|
99
99
|
if (!ENV_SHADOW_WARN.has(key)) continue;
|
|
100
|
+
// Only warn when process.env has a non-empty competing value.
|
|
101
|
+
// An empty-string env var (e.g. Claude for Desktop clears ANTHROPIC_API_KEY
|
|
102
|
+
// so its internal auth is used instead) is not a real conflict — config.env wins
|
|
103
|
+
// silently and no user action is needed.
|
|
104
|
+
if (!env[key]) continue;
|
|
100
105
|
if (key in fileEnv && fileEnv[key] !== env[key]) {
|
|
101
106
|
process.stderr.write(
|
|
102
|
-
`[construct] WARNING: process.env.${key} (${env[key].slice(0, 6)}…)
|
|
103
|
-
`config.env value (${fileEnv[key].slice(0, 6)}…). ` +
|
|
104
|
-
`
|
|
107
|
+
`[construct] WARNING: process.env.${key} (${env[key].slice(0, 6)}…) ` +
|
|
108
|
+
`differs from config.env value (${fileEnv[key].slice(0, 6)}…). ` +
|
|
109
|
+
`config.env will be used. To silence: unset the shell variable or ` +
|
|
110
|
+
`update ~/.construct/config.env to match.\n`,
|
|
105
111
|
);
|
|
106
112
|
}
|
|
107
113
|
}
|
|
@@ -95,9 +95,8 @@ export async function evaluateDocument({ content, docType, evaluatorFn = null })
|
|
|
95
95
|
return await evaluatorFn({ content, docType, rubric });
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
// Default: deterministic rubric
|
|
99
|
-
//
|
|
100
|
-
// For now, we use structural checks as a proxy
|
|
98
|
+
// Default path: deterministic structural-rubric evaluation.
|
|
99
|
+
// Consumers wanting LLM-graded evaluation supply `evaluatorFn` (handled above).
|
|
101
100
|
|
|
102
101
|
const scores = [];
|
|
103
102
|
const feedback = [];
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/extractors/calendar.mjs — RFC 5545 iCalendar (.ics) structured extraction.
|
|
3
|
+
*
|
|
4
|
+
* Parses VCALENDAR / VEVENT blocks without external dependencies. Handles
|
|
5
|
+
* multi-line property continuation (lines starting with whitespace), extracts
|
|
6
|
+
* SUMMARY, DTSTART, DTEND, ORGANIZER, ATTENDEE, DESCRIPTION, LOCATION, and
|
|
7
|
+
* preserves RRULE as a raw string for recurring events.
|
|
8
|
+
*
|
|
9
|
+
* Returns the universal { text, structured, droppedInfo } envelope where
|
|
10
|
+
* structured = { events: [{ summary, dtstart, dtend, organizer,
|
|
11
|
+
* attendees: [], description, location, rrule }] }.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { makeEnvelope, makeDropInfo } from './shared/drop-info.mjs';
|
|
15
|
+
|
|
16
|
+
// ─── RFC 5545 helpers ─────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
// Unfold continuation lines (RFC 5545 §3.1 — a line beginning with SPACE or TAB
|
|
19
|
+
// is a continuation of the previous property line).
|
|
20
|
+
function unfoldLines(raw) {
|
|
21
|
+
return raw.replace(/\r\n/g, '\n').replace(/\n[ \t]/g, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Split a property line into its name, params object, and value.
|
|
25
|
+
// e.g. "DTSTART;TZID=America/New_York:20240101T090000" → name=DTSTART, params={TZID:...}, value=...
|
|
26
|
+
function parseProperty(line) {
|
|
27
|
+
const colonIdx = line.indexOf(':');
|
|
28
|
+
if (colonIdx === -1) return { name: line.toUpperCase(), params: {}, value: '' };
|
|
29
|
+
const nameAndParams = line.slice(0, colonIdx);
|
|
30
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
31
|
+
const parts = nameAndParams.split(';');
|
|
32
|
+
const name = (parts[0] || '').toUpperCase().trim();
|
|
33
|
+
const params = {};
|
|
34
|
+
for (const part of parts.slice(1)) {
|
|
35
|
+
const eqIdx = part.indexOf('=');
|
|
36
|
+
if (eqIdx === -1) continue;
|
|
37
|
+
params[part.slice(0, eqIdx).toUpperCase()] = part.slice(eqIdx + 1);
|
|
38
|
+
}
|
|
39
|
+
return { name, params, value };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Extract a human-readable name from an ORGANIZER or ATTENDEE value.
|
|
43
|
+
// Tries the CN param first, then strips mailto: prefix.
|
|
44
|
+
function extractName(value, params) {
|
|
45
|
+
if (params.CN) return params.CN.replace(/^["']|["']$/g, '');
|
|
46
|
+
return value.replace(/^mailto:/i, '');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Parse an iCal date/datetime string into an ISO string. Tolerant of both
|
|
50
|
+
// date-only (YYYYMMDD) and datetime (YYYYMMDDTHHMMSS[Z]) forms.
|
|
51
|
+
function parseIcalDate(value) {
|
|
52
|
+
if (!value) return null;
|
|
53
|
+
const v = value.replace('Z', '').trim();
|
|
54
|
+
if (v.length === 8) {
|
|
55
|
+
// Date-only: YYYYMMDD
|
|
56
|
+
return `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}`;
|
|
57
|
+
}
|
|
58
|
+
if (v.length >= 15 && v[8] === 'T') {
|
|
59
|
+
// Datetime: YYYYMMDDTHHMMSS
|
|
60
|
+
return `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}T${v.slice(9, 11)}:${v.slice(11, 13)}:${v.slice(13, 15)}${value.endsWith('Z') ? 'Z' : ''}`;
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── VEVENT block parser ──────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
function parseVEvent(lines) {
|
|
68
|
+
const event = {
|
|
69
|
+
summary: null,
|
|
70
|
+
dtstart: null,
|
|
71
|
+
dtend: null,
|
|
72
|
+
organizer: null,
|
|
73
|
+
attendees: [],
|
|
74
|
+
description: null,
|
|
75
|
+
location: null,
|
|
76
|
+
rrule: null,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
if (!line.includes(':')) continue;
|
|
81
|
+
const { name, params, value } = parseProperty(line);
|
|
82
|
+
switch (name) {
|
|
83
|
+
case 'SUMMARY': event.summary = value; break;
|
|
84
|
+
case 'DTSTART': event.dtstart = parseIcalDate(value); break;
|
|
85
|
+
case 'DTEND': event.dtend = parseIcalDate(value); break;
|
|
86
|
+
case 'DTDUE': event.dtend = event.dtend ?? parseIcalDate(value); break;
|
|
87
|
+
case 'ORGANIZER': event.organizer = extractName(value, params); break;
|
|
88
|
+
case 'ATTENDEE': event.attendees.push(extractName(value, params)); break;
|
|
89
|
+
case 'DESCRIPTION': event.description = value.replace(/\\n/g, '\n').replace(/\\,/g, ','); break;
|
|
90
|
+
case 'LOCATION': event.location = value; break;
|
|
91
|
+
case 'RRULE': event.rrule = value; break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return event;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── VCALENDAR parser ─────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
function parseIcs(raw) {
|
|
100
|
+
const unfolded = unfoldLines(raw);
|
|
101
|
+
const lines = unfolded.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
102
|
+
|
|
103
|
+
if (!lines.some((l) => l === 'BEGIN:VCALENDAR')) {
|
|
104
|
+
return makeEnvelope({
|
|
105
|
+
text: raw.replace(/\r\n/g, '\n').trim(),
|
|
106
|
+
structured: null,
|
|
107
|
+
droppedInfo: [makeDropInfo({ kind: 'attachment', count: 1, reason: 'No BEGIN:VCALENDAR found; treating as plain text', recoverable: false })],
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const events = [];
|
|
112
|
+
let inEvent = false;
|
|
113
|
+
let eventLines = [];
|
|
114
|
+
let droppedTodosVjournals = 0;
|
|
115
|
+
let inOther = false;
|
|
116
|
+
|
|
117
|
+
for (const line of lines) {
|
|
118
|
+
if (line === 'BEGIN:VEVENT') {
|
|
119
|
+
inEvent = true;
|
|
120
|
+
eventLines = [];
|
|
121
|
+
} else if (line === 'END:VEVENT') {
|
|
122
|
+
events.push(parseVEvent(eventLines));
|
|
123
|
+
inEvent = false;
|
|
124
|
+
} else if (line === 'BEGIN:VTODO' || line === 'BEGIN:VJOURNAL') {
|
|
125
|
+
inOther = true;
|
|
126
|
+
droppedTodosVjournals++;
|
|
127
|
+
} else if (line === 'END:VTODO' || line === 'END:VJOURNAL') {
|
|
128
|
+
inOther = false;
|
|
129
|
+
} else if (inEvent) {
|
|
130
|
+
eventLines.push(line);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const droppedInfo = [];
|
|
135
|
+
if (droppedTodosVjournals > 0) {
|
|
136
|
+
droppedInfo.push(makeDropInfo({
|
|
137
|
+
kind: 'attachment',
|
|
138
|
+
count: droppedTodosVjournals,
|
|
139
|
+
reason: 'VTODO and VJOURNAL components omitted (only VEVENT extracted)',
|
|
140
|
+
recoverable: false,
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Build text representation
|
|
145
|
+
const textParts = events.map((e) => {
|
|
146
|
+
const parts = [];
|
|
147
|
+
if (e.summary) parts.push(`Event: ${e.summary}`);
|
|
148
|
+
if (e.dtstart) parts.push(`Start: ${e.dtstart}`);
|
|
149
|
+
if (e.dtend) parts.push(`End: ${e.dtend}`);
|
|
150
|
+
if (e.organizer) parts.push(`Organizer: ${e.organizer}`);
|
|
151
|
+
if (e.attendees.length) parts.push(`Attendees: ${e.attendees.join(', ')}`);
|
|
152
|
+
if (e.location) parts.push(`Location: ${e.location}`);
|
|
153
|
+
if (e.description) parts.push(`\n${e.description}`);
|
|
154
|
+
return parts.join('\n');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return makeEnvelope({
|
|
158
|
+
text: textParts.join('\n\n').trim(),
|
|
159
|
+
structured: { events },
|
|
160
|
+
droppedInfo,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Extract structured calendar data from a .ics file path.
|
|
168
|
+
* Returns the universal { text, structured, droppedInfo } envelope.
|
|
169
|
+
*/
|
|
170
|
+
export function extractCalendar(filePath) {
|
|
171
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
172
|
+
return parseIcs(raw);
|
|
173
|
+
}
|