Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
943351c
feat(dashboard): scaffold Vite + React package with Tailwind
kevinten10 Mar 17, 2026
c30c4c8
feat(dashboard): add WebSocket RPC client with auto-reconnect
kevinten10 Mar 17, 2026
3f799e6
feat(dashboard): add Zustand stores for gateway, realms, family
kevinten10 Mar 17, 2026
32100fc
feat(dashboard): add app shell with sidebar and mobile navigation
kevinten10 Mar 17, 2026
cd0de66
feat(dashboard): add Home page with Realm grid and Timeline
kevinten10 Mar 17, 2026
fdc567a
feat(dashboard): add Route View with React Flow topology graph
kevinten10 Mar 17, 2026
03b967a
feat(ink): serve dashboard static files from HTTP port
kevinten10 Mar 17, 2026
84db448
feat(dashboard): add Members page with member cards
kevinten10 Mar 17, 2026
3563be1
feat(dashboard): add Entities page with entity cards and SOUL editor
kevinten10 Mar 17, 2026
93c7786
feat(dashboard): add Settings page
kevinten10 Mar 17, 2026
c2af3da
feat(dashboard): add i18n with Chinese and English
kevinten10 Mar 17, 2026
ed7b56a
feat(dashboard): add gateway hook, fix Tailwind v4, complete integration
kevinten10 Mar 17, 2026
9769c86
fix(ci): remove hardcoded pnpm version to match packageManager field
kevinten10 Mar 19, 2026
44dbf59
fix(dashboard): resolve oxlint errors - remove unused Placeholder, us…
kevinten10 Mar 19, 2026
0809ea1
refactor(dashboard): wire gateway hook into Shell, add connection sta…
kevinten10 Mar 19, 2026
16b4ad5
feat(dashboard): add realm and routing hooks, wire store hydration to…
kevinten10 Mar 19, 2026
3edfb6d
feat(dashboard): wire all pages to Zustand stores with placeholder fa…
kevinten10 Mar 19, 2026
802da05
fix(dashboard): fix gateway store error handling, wire Settings, remo…
kevinten10 Mar 19, 2026
716b3dd
fix: resolve 69 lint errors across codebase (148 → 79 warnings)
kevinten10 Mar 19, 2026
b16ac36
fix(ink): use correct RealmState type instead of undefined Realm
kevinten10 Mar 19, 2026
08fd25e
refactor(dashboard): extract shared nav config and add error boundary
kevinten10 Mar 20, 2026
014fe1b
fix(dashboard): switch to createBrowserRouter for correct basename ha…
kevinten10 Mar 20, 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
2 changes: 0 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ node_modules/
# Build output
dist/
*.tsbuildinfo
packages/ink/public/dashboard/
packages/dashboard/vite.config.js
packages/dashboard/vite.config.d.ts

# Test & coverage
coverage/
Expand Down
5 changes: 3 additions & 2 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
},
"rules": {
"curly": "error",
"typescript/no-explicit-any": "error",
"typescript/no-unsafe-type-assertion": "off"
"typescript/no-explicit-any": "warn",
"typescript/no-unsafe-type-assertion": "off",
"no-await-in-loop": "warn"
},
"ignorePatterns": [
"dist/",
Expand Down
4 changes: 4 additions & 0 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ const config: KnipConfig = {
entry: ["src/index.ts"],
ignoreDependencies: ["@openoctopus/shared", "@openoctopus/core"],
},
"packages/dashboard": {
entry: ["src/main.tsx"],
ignoreDependencies: ["@openoctopus/shared", "autoprefixer", "postcss"],
},
},
ignore: [
"**/*.test.ts",
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
"start": "tsx packages/tentacle/src/cli.ts start",
"chat": "tsx packages/tentacle/src/cli.ts chat",
"restart": "pnpm build && tsx packages/tentacle/src/cli.ts start",
"dashboard:dev": "pnpm --filter @openoctopus/dashboard dev",
"dashboard:build": "pnpm --filter @openoctopus/dashboard build",
"clean": "pnpm -r exec rm -rf dist",
"doctor": "pnpm check && pnpm test:unit"
},
Expand Down
14 changes: 7 additions & 7 deletions packages/core/src/cross-realm-reactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export class CrossRealmReactor {
const { sourceRealmId, userMessage, assistantResponse, onReaction } = params;

const activeSummoned = this.summonEngine.listActive();
if (activeSummoned.length === 0) return;
if (activeSummoned.length === 0) {return;}

const realms = this.realmManager.list();
const combinedText = `${userMessage} ${assistantResponse}`;
Expand All @@ -56,10 +56,10 @@ export class CrossRealmReactor {

for (const summoned of activeSummoned) {
// Skip agents in the source realm
if (summoned.entity.realmId === sourceRealmId) continue;
if (summoned.entity.realmId === sourceRealmId) {continue;}

const targetRealm = realms.find(r => r.id === summoned.entity.realmId);
if (!targetRealm) continue;
if (!targetRealm) {continue;}

const score = this.computeRelevanceScore(combinedText, targetRealm);
if (score > 0 && (!bestMatch || score > bestMatch.score)) {
Expand All @@ -72,7 +72,7 @@ export class CrossRealmReactor {
}
}

if (!bestMatch) return;
if (!bestMatch) {return;}

// Generate reaction from the best-matching agent
try {
Expand Down Expand Up @@ -108,7 +108,7 @@ export class CrossRealmReactor {

let score = 0;
for (const kw of keywords) {
if (lowered.includes(kw)) score++;
if (lowered.includes(kw)) {score++;}
}

return score;
Expand All @@ -121,7 +121,7 @@ export class CrossRealmReactor {
sourceRealmId: string,
conversationSummary: string,
): Promise<string | null> {
if (!this.llmRegistry.hasRealProvider()) return null;
if (!this.llmRegistry.hasRealProvider()) {return null;}

try {
const provider = this.llmRegistry.getProvider();
Expand All @@ -143,7 +143,7 @@ If nothing truly relevant from your domain's perspective, respond with exactly "
});

const content = result.content.trim();
if (content === "SKIP" || content.length < 5) return null;
if (content === "SKIP" || content.length < 5) {return null;}

return content;
} catch (err) {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/directory-scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ describe("DirectoryScanner", () => {
it("should not call distributeFromText in dryRun mode", async () => {
fs.writeFileSync(path.join(tmpDir, "notes.md"), "some content");

const result = await scanner.scanDirectory(tmpDir, { dryRun: true });
await scanner.scanDirectory(tmpDir, { dryRun: true });
expect(mockKnowledgeDistributor.distributeFromText).not.toHaveBeenCalled();
});

Expand Down
12 changes: 6 additions & 6 deletions packages/core/src/directory-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,22 +178,22 @@ export class DirectoryScanner {
resolvedPath,
{ recursive: true, persistent: false },
(eventType, filename) => {
if (!filename || closed) return;
if (!filename || closed) {return;}

// Check extension
const ext = path.extname(filename).toLowerCase();
if (!extensions.includes(ext)) return;
if (!extensions.includes(ext)) {return;}

const filePath = path.join(resolvedPath, filename);

// Debounce: clear any pending scan for this file
const existing = pendingScans.get(filePath);
if (existing) clearTimeout(existing);
if (existing) {clearTimeout(existing);}

// Schedule new scan
const timer = setTimeout(() => {
pendingScans.delete(filePath);
if (closed) return;
if (closed) {return;}

// Scan the file (fire-and-forget)
this.scanFile(filePath).catch((err) => {
Expand Down Expand Up @@ -239,9 +239,9 @@ export class DirectoryScanner {

for (const entry of entries) {
// Skip hidden files/dirs
if (entry.name.startsWith(".")) continue;
if (entry.name.startsWith(".")) {continue;}
// Skip node_modules, etc.
if (entry.name === "node_modules" || entry.name === "__pycache__") continue;
if (entry.name === "node_modules" || entry.name === "__pycache__") {continue;}

const fullPath = path.join(dirPath, entry.name);

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/embedding/embedding-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export class EmbeddingProviderRegistry {

// Try exact match
const provider = this.providers.get(providerName);
if (provider) return provider;
if (provider) {return provider;}

// Try first configured provider
if (this.providerOrder.length > 0) {
Expand Down
12 changes: 6 additions & 6 deletions packages/core/src/knowledge-distributor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export class KnowledgeDistributor {
const classification = await this.classifyToRealm(fact, realms);

// Skip if the fact belongs to the source realm (already handled)
if (!classification || classification.realmId === sourceRealmId) continue;
if (!classification || classification.realmId === sourceRealmId) {continue;}

this.memoryRepo.create({
realmId: classification.realmId,
Expand Down Expand Up @@ -224,7 +224,7 @@ Write facts in the same language as the input.`,
.filter(item => item.fact && item.realm)
.map((item): ExtractedFact | null => {
const matchedRealm = realms.find(r => r.name.toLowerCase() === item.realm.toLowerCase());
if (!matchedRealm) return null;
if (!matchedRealm) {return null;}
return {
content: item.fact,
realmId: matchedRealm.id,
Expand Down Expand Up @@ -252,7 +252,7 @@ Write facts in the same language as the input.`,
const keywords = REALM_KEYWORDS[realm.name.toLowerCase()] ?? [];
let score = 0;
for (const kw of keywords) {
if (lowered.includes(kw)) score++;
if (lowered.includes(kw)) {score++;}
}
if (score > bestScore) {
bestScore = score;
Expand Down Expand Up @@ -288,7 +288,7 @@ Write facts in the same language as the input.`,
const keywords = REALM_KEYWORDS[realm.name.toLowerCase()] ?? [];
let score = 0;
for (const kw of keywords) {
if (lowered.includes(kw)) score++;
if (lowered.includes(kw)) {score++;}
}
if (score > bestScore) {
bestScore = score;
Expand All @@ -304,11 +304,11 @@ Write facts in the same language as the input.`,
}

private async createMissingEntity(fact: ExtractedFact): Promise<void> {
if (!fact.entityName) return;
if (!fact.entityName) {return;}

try {
const existing = this.entityManager.findByNameInRealm(fact.realmId, fact.entityName);
if (existing) return;
if (existing) {return;}

const validTypes = ["living", "asset", "organization", "abstract"] as const;
const entityType = validTypes.includes(fact.entityType as typeof validTypes[number])
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/maturity-scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,11 +269,11 @@ describe("MaturityScanner", () => {
{ id: "r2", name: "finance" },
]);
mockEntityManager.listByRealm.mockImplementation((realmId: string) => {
if (realmId === "r1") return [{ id: "e1", name: "Luna", realmId: "r1", attributes: { a: 1, b: 2, c: 3 }, summonStatus: "dormant" }];
if (realmId === "r1") {return [{ id: "e1", name: "Luna", realmId: "r1", attributes: { a: 1, b: 2, c: 3 }, summonStatus: "dormant" }];}
return [{ id: "e2", name: "Account", realmId: "r2", attributes: { x: 1 }, summonStatus: "dormant" }];
});
mockEntityManager.get.mockImplementation((id: string) => {
if (id === "e1") return { id: "e1", name: "Luna", realmId: "r1", attributes: { a: 1, b: 2, c: 3 }, summonStatus: "dormant" };
if (id === "e1") {return { id: "e1", name: "Luna", realmId: "r1", attributes: { a: 1, b: 2, c: 3 }, summonStatus: "dormant" };}
return { id: "e2", name: "Account", realmId: "r2", attributes: { x: 1 }, summonStatus: "dormant" };
});
mockRealmManager.get.mockImplementation((id: string) => ({ id, name: id === "r1" ? "pet" : "finance" }));
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/maturity-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export class MaturityScanner {

computeEntityMaturity(entityId: string): MaturityScore {
const entity = this.entityManager.get(entityId);
const realm = this.realmManager.get(entity.realmId);
const _realm = this.realmManager.get(entity.realmId);

// Attribute completeness (30%): non-empty attributes / total defined attributes
const attrs = entity.attributes;
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/memory-extractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@ const mockEmbeddingRegistry = {

/** Helper: create a unit vector pointing along a single dimension */
function basisVector(dim: number, size = 128): number[] {
const v = new Array(size).fill(0);
const v = Array.from({ length: size }, () => 0);
v[dim] = 1;
return v;
}

/** Helper: create a vector that is `similarity` cosine-similar to a basis vector at dim */
function vectorWithSimilarity(baseDim: number, similarity: number, size = 128): number[] {
// Start with the basis vector scaled by similarity, add orthogonal component
const v = new Array(size).fill(0);
const v = Array.from({ length: size }, () => 0);
v[baseDim] = similarity;
// Add orthogonal component in the next dimension to make it a unit vector
const orthDim = (baseDim + 1) % size;
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/memory-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export class MemoryExtractor {
try {
const extraction = await this.extractFacts(params.userMessage, params.assistantMessage);
const { facts, importance, relations } = extraction;
if (facts.length === 0) return { memories: [], attributeUpdates: [] };
if (facts.length === 0) {return { memories: [], attributeUpdates: [] };}

const entries: MemoryEntry[] = [];
for (let i = 0; i < facts.length; i++) {
Expand Down Expand Up @@ -296,7 +296,7 @@ export class MemoryExtractor {
relations: ExtractionResult["relations"],
realmId: string,
): Promise<void> {
if (!this.knowledgeGraphRepo || relations.length === 0) return;
if (!this.knowledgeGraphRepo || relations.length === 0) {return;}

for (const rel of relations) {
try {
Expand Down
9 changes: 4 additions & 5 deletions packages/core/src/memory-health-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,6 @@ export class MemoryHealthManager {
for (const issue of dups) {
if (issue.memoryIds.length >= 2) {
// Keep first, delete rest
const toDelete = issue.memoryIds.slice(1);
deduplicatedCount += await this.deduplicate(realmId, [issue.memoryIds]);
}
}
Expand Down Expand Up @@ -337,7 +336,7 @@ If no contradictions found, output [].`,
function normalizedLevenshtein(a: string, b: string): number {
if (a === b) { return 0; }
const maxLen = Math.max(a.length, b.length);
if (maxLen === 0) return 0;
if (maxLen === 0) {return 0;}

// For performance, skip very long strings
if (maxLen > 500) {
Expand All @@ -346,7 +345,7 @@ function normalizedLevenshtein(a: string, b: string): number {
const setB = new Set(b.toLowerCase().split(/\s+/));
let overlap = 0;
for (const w of setA) {
if (setB.has(w)) overlap++;
if (setB.has(w)) {overlap++;}
}
const union = new Set([...setA, ...setB]).size;
return union > 0 ? 1 - overlap / union : 1;
Expand All @@ -356,8 +355,8 @@ function normalizedLevenshtein(a: string, b: string): number {
const n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0) as number[]);

for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 0; i <= m; i++) {dp[i][0] = i;}
for (let j = 0; j <= n; j++) {dp[0][j] = j;}

for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
Expand Down
22 changes: 11 additions & 11 deletions packages/core/src/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,26 +25,26 @@ const TRIGGER_MAP: Record<string, string> = {

/** Simple cron expression validator (5-field format) */
const CRON_REGEX =
/^(\*|[0-9,\-\/]+)\s+(\*|[0-9,\-\/]+)\s+(\*|[0-9,\-\/]+)\s+(\*|[0-9,\-\/]+)\s+(\*|[0-9,\-\/]+)$/;
/^(\*|[0-9,\-/]+)\s+(\*|[0-9,\-/]+)\s+(\*|[0-9,\-/]+)\s+(\*|[0-9,\-/]+)\s+(\*|[0-9,\-/]+)$/;

/** Match "every day Xam" or "every day Xpm" */
const TIME_REGEX = /^every\s+day\s+(\d{1,2})(am|pm)$/i;

/** Convert cron expression to milliseconds interval (MVP approximation) */
function cronToInterval(cron: string): number {
const parts = cron.split(/\s+/);
if (parts.length !== 5) return 24 * 60 * 60 * 1000; // default: daily
if (parts.length !== 5) {return 24 * 60 * 60 * 1000;} // default: daily

const [_minute, hour, dayOfMonth, , dayOfWeek] = parts;

// "0 * * * *" → hourly
if (hour === "*") return 60 * 60 * 1000;
if (hour === "*") {return 60 * 60 * 1000;}

// "0 9 * * 1" → weekly (day of week specified)
if (dayOfWeek !== "*") return 7 * 24 * 60 * 60 * 1000;
if (dayOfWeek !== "*") {return 7 * 24 * 60 * 60 * 1000;}

// "0 9 1 * *" → monthly (day of month specified, not *)
if (dayOfMonth !== "*") return 30 * 24 * 60 * 60 * 1000;
if (dayOfMonth !== "*") {return 30 * 24 * 60 * 60 * 1000;}

// Default: daily
return 24 * 60 * 60 * 1000;
Expand All @@ -59,23 +59,23 @@ export class Scheduler {
/** Parse a human-readable trigger or cron expression */
static parseTrigger(trigger: string): string | null {
const trimmed = trigger.trim().toLowerCase();
if (!trimmed) return null;
if (!trimmed) {return null;}

// Check exact matches in map
if (TRIGGER_MAP[trimmed]) return TRIGGER_MAP[trimmed];
if (TRIGGER_MAP[trimmed]) {return TRIGGER_MAP[trimmed];}

// Check "every day Xam/pm" pattern
const timeMatch = trimmed.match(TIME_REGEX);
if (timeMatch) {
let hour = parseInt(timeMatch[1], 10);
const period = timeMatch[2].toLowerCase();
if (period === "pm" && hour !== 12) hour += 12;
if (period === "am" && hour === 12) hour = 0;
if (period === "pm" && hour !== 12) {hour += 12;}
if (period === "am" && hour === 12) {hour = 0;}
return `0 ${hour} * * *`;
}

// Check raw cron expression
if (CRON_REGEX.test(trimmed)) return trimmed;
if (CRON_REGEX.test(trimmed)) {return trimmed;}

return null;
}
Expand Down Expand Up @@ -124,7 +124,7 @@ export class Scheduler {
}

start(): void {
if (this.running) return;
if (this.running) {return;}
this.running = true;

for (const rule of this.rules) {
Expand Down
13 changes: 13 additions & 0 deletions packages/dashboard/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenOctopus - 家庭管家</title>
<link rel="icon" href="/dashboard/favicon.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Loading
Loading