Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
687755f
feat(ce): Phase 2a proxy prefix + hosted hook + migration intake
benjsmith Sep 18, 2026
da0523a
feat(ce): Phase 2b filebrowser slice + replay timeline hook
benjsmith Sep 18, 2026
af2f06e
feat(ce): Phase 2b+ wiki partition / workspace split spike
benjsmith Sep 18, 2026
c9dda3c
feat(ce): Phase 2b++ filebrowser FS mutate + pack routes stub
benjsmith Sep 19, 2026
75d7e5d
feat(ce): Phase 2b+++ curation replay SVG UI + history API
benjsmith Sep 19, 2026
7a55260
feat(ce): Phase 2b++++ pack action dispatch under vault/wiki sandbox
benjsmith Sep 19, 2026
bbe62b7
feat(ce): Phase 2b+++++ workspace registry + CM export hook
benjsmith Sep 19, 2026
080da5a
feat(ce): Phase 2b++++++ rubber-band split targeting UI
benjsmith Sep 19, 2026
ba5bd21
feat(ce): polish curation replay toward Switchbay CurationReplay
benjsmith Sep 19, 2026
f3da38f
feat(ce): Atlas canvas rubber-band + curation replay camera bind
benjsmith Sep 19, 2026
bcc8c59
feat(ce): filebrowser reveal-in-OS + drop-ingest + git pack install
benjsmith Sep 19, 2026
2d6af32
feat(ce): Obsidian vault import (folder/zip → wiki/)
benjsmith Sep 20, 2026
664f869
feat(wiki-view): CEEmbed.create dual-mount API for Switchbay embed
benjsmith Sep 27, 2026
970c1fd
fix(atlas): clear hover/selection when pointer leaves canvas
benjsmith Sep 27, 2026
9983bfc
fix(atlas): hub collide radius + search min-prefix lag
benjsmith Sep 27, 2026
994d3d4
fix(atlas): stronger hub collide for N>10k (v2)
benjsmith Sep 27, 2026
aa32c2a
fix(embed): soft-park canvas on unmount for instant Graph return
benjsmith Sep 27, 2026
f42caea
fix(filebrowser): keep Pages↔Files warm — fast tree + skip remount
benjsmith Sep 27, 2026
db91631
feat(embed): IndexedDB atlas cache for instant hard-reload Graph
benjsmith Sep 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,101 @@
## 2026-09-20 — Obsidian vault import (folder + zip)

**Migration:** none. **Breaking:** none.

### Added

- **`scripts/obsidian_import.py`:** import an Obsidian vault (folder path or zip)
into a CE workspace as `wiki/`. Preserves `[[wikilinks]]` verbatim. Sandboxes
sources under `$HOME` / `$CE_WORKSPACE_HOME` (same as workspace registry);
zip-slip safe extract. Optional `--target` bootstraps a minimal workspace;
`--register` records it in the CE workspace registry.
- **`POST /api/import/obsidian`:** JSON `{path}` or multipart zip; honours
`CE_PUBLIC_BASE` embed prefix.
- **CLI:** `python3 obsidian_import.py <path>` (conceptual `ce import-obsidian`).
- **Tests:** `tests/test_obsidian_import.py`.
- **Fixture (umbrella):** `corpora/obsidian-dummy-vault/` for Omarchy E2E prep.

### Notes

- **Mapping:** Obsidian vault root → CE `wiki/` (documented; matches existing
“open wiki/ as Obsidian vault” guidance in `docs/viewers.md`).
- Omarchy GUI pick-folder remains shell-side later.

## 2026-09-19 — Phase 2b++++++ rubber-band split targeting UI

**Migration:** none. **Breaking:** none.

### Added

- **Classic graph rubber-band:** Ctrl/⌘-drag marquee select; Shift-click multi-select;
click toggle membership; Alt-click / right-click flips move↔copy (entities/concepts
default to copy). `Graph.splitEnter` / `splitExit` sync with the split panel.
- **Split panel:** listens `ce:split-selection`, `ce:split-proposal`, and
`sy:split-proposal`; still POSTs `/api/split` via `ceApi` / `CE_PUBLIC_BASE`
(embed v2 under `/embed/ce`).
- **Pure helpers:** `packages/knowledge-atlas` `rubberBand.ts` + `rubberBand.test.ts`.
- Atlas: `splitEnter`/`splitExit` seed engine multi-select (canvas marquee deferred).

### Notes

- Still Switchbay-only / deferred: Atlas canvas rubber-band, link-heal agents,
tab open after split, workspace-root figures / `.workbench` sketches, OS Trash prune.
See `skills/curiosity-engine/docs/MIGRATION-INTAKE.md`.

## 2026-09-19 — Phase 2b+++++ split registry + CM export hook

**Migration:** none. **Breaking:** none.

### Added

- **Workspace registry** (`workspace_registry.py`): CE-owned persistence under
`$CE_WORKSPACE_REGISTRY` or `$XDG_CONFIG_HOME/curiosity-engine/workspaces.json`,
sandboxed to `$HOME` / `$CE_WORKSPACE_HOME`. Records split provenance.
- **APIs:** `GET/POST/DELETE /api/workspaces`; successful `POST /api/split` auto-registers
the target (shells still own tab-open chrome).
- **CM export hook** (`cm_export.py` + `POST /api/cm-export`): invokes curiosity-merge
`subgraph_export.py` for a selected page set (license modes via `--include-vault`).
Supports `dry_run` (plan argv, no spawn). Discovers CM via `$CE_CM_ROOT` /
`$CURIOSITY_MERGE_ROOT` / sibling checkout / skill install paths. Target sandboxed
outside the source workspace and inside the registry home root.
- **Tests:** `tests/test_workspace_registry_cm_export.py` (registry persistence +
export dry-run + split auto-register under embed prefix).

### Notes

- Native `wiki_partition` remains the default split path (no CM required).
- Still Switchbay-only: rubber-band / `sy:split-proposal` UI, link-heal agents,
tab open after split, workspace-root figures / `.workbench` sketches, OS Trash prune.
See `skills/curiosity-engine/docs/MIGRATION-INTAKE.md`.

## 2026-09-19 — Phase 2b++++ pack action dispatch

**Migration:** none. **Breaking:** none.

### Added

- Pack APIs (Switchbay `pack.json` shape): `GET /api/packs`, `GET /api/packs/<name>/actions`,
`POST /api/packs/<pack>/action/<action>` (vault/wiki sandbox queue → `{run_id}`),
`POST /api/packs/toggle`, `POST /api/packs/install` (local path), `DELETE /api/packs?name=`.
- Filebrowser context menu runs pack `file_routes` via the dispatch API.
- Tests: `tests/test_filebrowser_packs.py` (escape / disabled / embed prefix).

### Notes

- Agent/LLM skill execution, git pack install, pip extras, reveal-in-OS, and drop-ingest
remain Switchbay-only (see `MIGRATION-INTAKE.md`).

## 2026-09-19 — Phase 2b++ filebrowser FS mutate + pack routes stub

**Migration:** none. **Breaking:** none.

### Added

- Sandboxed FS mutate APIs under `vault/` + `wiki/`: `POST /api/fs/{create,mkdir,rename,move,delete,duplicate}`, `GET /api/fs/stat`.
- Pack `file_routes` discovery: `GET /api/file-routes` (read-only; install/dispatch stays shell-side).
- Wiki-view Files context menu: New file/folder, Rename, Duplicate, Delete.
- Tests: `tests/test_filebrowser_fs.py` (sandbox escape + embed-prefix API).

## 2026-09-16 — Wiki viewer mobile modal + sidebar

**Migration:** none. **Breaking:** none.
Expand Down
2 changes: 1 addition & 1 deletion RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Run through this before tagging a new release. Items marked **(security)** must
| `d3.min.js` | 7.9.0 | `f2094bbf6141b359722c4fe454eb6c4b0f0e42cc10cc7af921fc158fceb86539` | `https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js` |
| `fuse.min.js` | 7.0.0 | `e3621b53cb77b4ec306dec41ed95511e6dd80d17fae5a04f3e346d214b9f8f92` | `https://cdn.jsdelivr.net/npm/fuse.js@7.0.0/dist/fuse.min.js` |
| `jszip.min.js` | 3.10.1 | `acc7e41455a80765b5fd9c7ee1b8078a6d160bbbca455aeae854de65c947d59e` | `https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js` |
| `knowledge-atlas.js` | 0.2.0 (in-repo) | `26ffe14abe0c3476702486c2796bb166c8c8e1275e2adb98ae3066b8609df7cd` | built in-repo: `cd packages/knowledge-atlas && pnpm run build`, then copy `dist/knowledge-atlas.iife.js` here (no CDN — first-party code; refresh the hash on every rebuild) |
| `knowledge-atlas.js` | 0.2.0 (in-repo) | `b2ba92eb162a1a71bb71a3fcfeda52450997d8e218a6f34eef4c71c627589e18` | built in-repo: `cd packages/knowledge-atlas && pnpm run build`, then copy `dist/knowledge-atlas.iife.js` here (no CDN — first-party code; refresh the hash on every rebuild) |

To refresh:

Expand Down
185 changes: 185 additions & 0 deletions packages/knowledge-atlas/src/animation/historyFromGraph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* Build / scrub helpers for curation-replay HistoryDoc.
*
* Synthesizes a deterministic timeline from CE `data.json` graph
* membership when git-first-seen history is unavailable. Hosts
* (wiki-view replay UI) own SVG rendering; this module stays pure.
*/

import type { HistoryDoc, ReplayEvent } from "./replayTimeline.ts";
import { compareSyntheticNodes } from "./replayCamera.ts";

export type GraphNodeIn = {
id: string;
title?: string;
type?: string;
degree?: number;
path?: string;
/** ISO date from frontmatter `created` when available. */
created?: string;
};

export type GraphEdgeIn = {
source: string | { id: string };
target: string | { id: string };
};

export type BuildHistoryOptions = {
/** Wall-clock span for event `t` values (seconds). */
duration?: number;
/** Cap total events (nodes kept; edges down-sampled). */
maxEvents?: number;
source?: string;
};

const DEFAULT_DURATION = 12;
const DEFAULT_MAX_EVENTS = 2500;

function edgeId(ref: string | { id: string } | null | undefined): string {
if (ref == null) return "";
if (typeof ref === "string") return ref;
return typeof ref.id === "string" ? ref.id : "";
}

/** Stable sort: time asc, nodes before edges at equal t. */
export function sortReplayEvents(events: ReplayEvent[]): ReplayEvent[] {
return events.slice().sort((a, b) => {
if (a.t !== b.t) return a.t - b.t;
const ao = a.op === "node" ? 0 : 1;
const bo = b.op === "node" ? 0 : 1;
return ao - bo;
});
}

/**
* Synthetic chronology: sources first, then by descending degree,
* then id. Edges fire at the later endpoint's slot so both ends exist.
*/
export function buildHistoryFromGraph(
nodesIn: readonly GraphNodeIn[] | null | undefined,
edgesIn: readonly GraphEdgeIn[] | null | undefined,
opts: BuildHistoryOptions = {},
): HistoryDoc {
const duration = opts.duration && opts.duration > 0 ? opts.duration : DEFAULT_DURATION;
const maxEvents = opts.maxEvents && opts.maxEvents > 0 ? opts.maxEvents : DEFAULT_MAX_EVENTS;
const source = opts.source ?? "ce-data-json";

const nodes = (nodesIn ?? [])
.map((n) => ({
id: String(n.id || "").trim(),
title: String(n.title || n.id || "").trim() || String(n.id || ""),
type: String(n.type || "unclassified"),
degree: typeof n.degree === "number" ? n.degree : 0,
created: typeof n.created === "string" ? n.created.trim() : "",
}))
.filter((n) => n.id);

if (!nodes.length) {
return { duration, events: [], source: "empty", node_count: 0, degree: {} };
}

// Prefer frontmatter `created` when present; else type-tier → degree → id.
nodes.sort(compareSyntheticNodes);

const byId = new Map(nodes.map((n) => [n.id, n]));
const indexOf = new Map(nodes.map((n, i) => [n.id, i]));
const nCount = nodes.length;
const tOf = (i: number) => (nCount <= 1 ? 0 : (i / (nCount - 1)) * duration);

const events: ReplayEvent[] = nodes.map((n, i) => ({
t: Math.round(tOf(i) * 1000) / 1000,
op: "node" as const,
id: n.id,
title: n.title,
type: n.type,
}));

const seenPairs = new Set<string>();
const edgeEvents: ReplayEvent[] = [];
for (const e of edgesIn ?? []) {
const s = edgeId(e.source);
const t = edgeId(e.target);
if (!s || !t || s === t || !byId.has(s) || !byId.has(t)) continue;
const pair = s < t ? `${s}|${t}` : `${t}|${s}`;
if (seenPairs.has(pair)) continue;
seenPairs.add(pair);
const later = Math.max(indexOf.get(s)!, indexOf.get(t)!);
edgeEvents.push({
t: Math.round(tOf(later) * 1000) / 1000,
op: "edge",
source: s,
target: t,
});
}

let merged = sortReplayEvents([...events, ...edgeEvents]);
if (merged.length > maxEvents) {
const nodeEv = merged.filter((e) => e.op === "node");
let edgeEv = merged.filter((e) => e.op === "edge");
const keep = Math.max(0, maxEvents - nodeEv.length);
if (keep < edgeEv.length) {
const step = edgeEv.length / Math.max(1, keep);
edgeEv = Array.from({ length: keep }, (_, i) => edgeEv[Math.floor(i * step)]!);
}
merged = sortReplayEvents([...nodeEv, ...edgeEv]);
}

const degree: Record<string, number> = {};
for (const n of nodes) degree[n.id] = 0;
for (const e of merged) {
if (e.op !== "edge") continue;
degree[e.source] = (degree[e.source] ?? 0) + 1;
degree[e.target] = (degree[e.target] ?? 0) + 1;
}

return {
duration,
events: merged,
source,
generated_at: Date.now() / 1000,
node_count: nodes.length,
degree,
};
}

export type ReplaySnapshot = {
/** Exclusive end index into sorted events (0 = empty). */
index: number;
nodes: Array<{ id: string; title: string; type: string }>;
edges: Array<{ source: string; target: string }>;
};

/** Apply events `[0, index)` into a structural snapshot for scrub/step. */
export function snapshotAt(
history: HistoryDoc | null | undefined,
index: number,
): ReplaySnapshot {
const events = sortReplayEvents(history?.events ?? []);
const n = events.length;
const clamped = Math.max(0, Math.min(n, Math.floor(index)));
const nodes: ReplaySnapshot["nodes"] = [];
const edges: ReplaySnapshot["edges"] = [];
const seen = new Set<string>();
for (let i = 0; i < clamped; i++) {
const ev = events[i]!;
if (ev.op === "node") {
if (seen.has(ev.id)) continue;
seen.add(ev.id);
nodes.push({ id: ev.id, title: ev.title, type: ev.type });
} else if (seen.has(ev.source) && seen.has(ev.target)) {
edges.push({ source: ev.source, target: ev.target });
}
}
return { index: clamped, nodes, edges };
}

/** Largest index such that all events with `t <= wall` are included. */
export function indexAtTime(
history: HistoryDoc | null | undefined,
wall: number,
): number {
const events = sortReplayEvents(history?.events ?? []);
let i = 0;
while (i < events.length && events[i]!.t <= wall) i += 1;
return i;
}
Loading