Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions src/cli/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
runIngestCommand,
runNgrokCommand,
runPrintCommand,
runStrategyCommand,
runVisualizeCommand,
} from "./runners.js";
import { runIntegrationsCommand, runMcpCommand } from "./integrations.js";
Expand Down Expand Up @@ -90,7 +91,13 @@
} else if (command.kind === "cron") {
await runCronCommand(command);
} else if (command.kind === "book") {
<<<<<<< HEAD

Check failure on line 94 in src/cli/cli.tsx

View workflow job for this annotation

GitHub Actions / Test (22)

Merge conflict marker encountered.

Check failure on line 94 in src/cli/cli.tsx

View workflow job for this annotation

GitHub Actions / Build, Typecheck & CLI Smoke (ubuntu-latest, Node 22)

Merge conflict marker encountered.

Check failure on line 94 in src/cli/cli.tsx

View workflow job for this annotation

GitHub Actions / Test (24)

Merge conflict marker encountered.

Check failure on line 94 in src/cli/cli.tsx

View workflow job for this annotation

GitHub Actions / Build, Typecheck & CLI Smoke (ubuntu-latest, Node 24)

Merge conflict marker encountered.
await runBookCommand(command);
} else if (command.kind === "strategy") {
await runStrategyCommand(command);
=======

Check failure on line 98 in src/cli/cli.tsx

View workflow job for this annotation

GitHub Actions / Test (22)

Merge conflict marker encountered.

Check failure on line 98 in src/cli/cli.tsx

View workflow job for this annotation

GitHub Actions / Build, Typecheck & CLI Smoke (ubuntu-latest, Node 22)

Merge conflict marker encountered.

Check failure on line 98 in src/cli/cli.tsx

View workflow job for this annotation

GitHub Actions / Test (24)

Merge conflict marker encountered.

Check failure on line 98 in src/cli/cli.tsx

View workflow job for this annotation

GitHub Actions / Build, Typecheck & CLI Smoke (ubuntu-latest, Node 24)

Merge conflict marker encountered.
await runBookCommand(command, command.mode);
>>>>>>> origin/main

Check failure on line 100 in src/cli/cli.tsx

View workflow job for this annotation

GitHub Actions / Test (22)

Merge conflict marker encountered.

Check failure on line 100 in src/cli/cli.tsx

View workflow job for this annotation

GitHub Actions / Build, Typecheck & CLI Smoke (ubuntu-latest, Node 22)

Merge conflict marker encountered.

Check failure on line 100 in src/cli/cli.tsx

View workflow job for this annotation

GitHub Actions / Test (24)

Merge conflict marker encountered.

Check failure on line 100 in src/cli/cli.tsx

View workflow job for this annotation

GitHub Actions / Build, Typecheck & CLI Smoke (ubuntu-latest, Node 24)

Merge conflict marker encountered.
} else if (command.kind === "ingest") {
await runIngestCommand(command);
} else if (command.kind === "visualize") {
Expand Down
38 changes: 38 additions & 0 deletions src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,12 @@ export type CliCommand =
name: string | null;
query: string | null;
}
| {
kind: "strategy";
action: "seed" | "list";
exitCode: 0;
description: string | null;
}
| { kind: "help"; exitCode: 0 }
| {
kind: "run";
Expand Down Expand Up @@ -192,6 +198,10 @@ export function parseCommand(argv: string[]): CliCommand {
return parseBookCommand(argv.slice(1));
}

if (argv[0] === "strategy") {
return parseStrategyCommand(argv.slice(1));
}

if (argv[0] === "auth") {
const action =
argv[1] === "configure"
Expand Down Expand Up @@ -832,6 +842,34 @@ function parseBookCommand(argv: string[]): CliCommand {
return { action, exitCode: 0, force, kind: "book", mode, name, query };
}

function parseStrategyCommand(argv: string[]): CliCommand {
const action = argv[0];

if (action !== "seed" && action !== "list") {
return {
exitCode: 1,
kind: "error",
message: "Usage: stratiki strategy seed <description> | list",
};
}

if (action === "list") {
return { action: "list", description: null, exitCode: 0, kind: "strategy" };
}

const description = argv.slice(1).join(" ").trim();

if (description.length === 0) {
return {
exitCode: 1,
kind: "error",
message: "Usage: stratiki strategy seed <description>",
};
}

return { action: "seed", description, exitCode: 0, kind: "strategy" };
}

/**
* Builds the registry-derived integration usage error.
*
Expand Down
58 changes: 58 additions & 0 deletions src/cli/runners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,64 @@ export async function runBookCommand(
await refreshBook(bookDir, bookDbPath);
}

/**
* Dispatches `stratiki strategy` subcommands: seed, list.
*/
export async function runStrategyCommand(
command: Extract<CliCommand, { kind: "strategy" }>,
): Promise<void> {
const { parseDecisionSeed } = await import("../strategy/parser.js");
const { decomposeDecision } = await import("../strategy/decomposer.js");
const { FileStrategyStore } = await import("../strategy/store.js");
const { openWikiStrategyDir } = await import("../config/openwiki-home.js");
const bookDir = path.join(process.cwd(), "openwiki");
const store = new FileStrategyStore(openWikiStrategyDir);

if (command.action === "list") {
const decisions = await store.listDecisions();
if (decisions.length === 0) {
process.stdout.write("No decisions seeded yet.\n");
return;
}

process.stdout.write(`Decisions (${decisions.length}):\n`);
for (const decision of decisions) {
const goals = await store.getGoalsForDecision(decision.id);
process.stdout.write(
`\n${decision.id}: ${decision.description}\n Status: ${decision.status}\n Goals: ${goals.length}\n`,
);
}
return;
}

if (command.description === null) {
process.stderr.write("Description is required for seed action.\n");
process.exitCode = 1;
return;
}

const decision = parseDecisionSeed({ description: command.description });
const index = await ContextIndex.buildFromDirectory(bookDir);
try {
const result = decomposeDecision(decision, index);
await store.saveDecision(result.decision);
await store.saveGoals(result.goals);

process.stdout.write(`Seeded decision: ${result.decision.id}\n`);
process.stdout.write(` ${result.decision.description}\n`);
process.stdout.write(`\nGenerated ${result.goals.length} goal(s):\n`);

const sortedGoals = [...result.goals].sort((a, b) => b.rank - a.rank);
for (const goal of sortedGoals) {
process.stdout.write(
`\n- [rank ${goal.rank}] ${goal.description}\n Grounded in: ${goal.groundedIn.length > 0 ? goal.groundedIn.join(", ") : "none"}\n`,
);
}
} finally {
index.close();
}
}

async function initBookManifest(
bookDir: string,
command: Extract<CliCommand, { kind: "book" }>,
Expand Down
3 changes: 3 additions & 0 deletions src/config/openwiki-home.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,11 @@ export const openWikiConversationHistoryDir = path.join(
export const openWikiLocalWikiDir = path.join(openWikiHomeDir, "wiki");
export const openWikiBookDbPath = path.join(openWikiHomeDir, "book.db");
export const openWikiSkillsDir = path.join(openWikiHomeDir, "skills");
export const openWikiStrategyDir = path.join(openWikiHomeDir, "strategy");
export const openWikiConnectorsDisplayPath = `${openWikiHomeDisplayPath}/connectors`;
export const openWikiLocalWikiDisplayPath = `${openWikiHomeDisplayPath}/wiki`;
export const openWikiSkillsDisplayPath = `${openWikiHomeDisplayPath}/skills`;
export const openWikiStrategyDisplayPath = `${openWikiHomeDisplayPath}/strategy`;
export const openWikiEnvDisplayPath = `${openWikiHomeDisplayPath}/.env`;

// Stratiki getters - lazy to avoid homedir() during module init
Expand Down Expand Up @@ -134,6 +136,7 @@ export async function ensureOpenWikiHome(): Promise<void> {
await mkdir(openWikiConversationHistoryDir, { recursive: true, mode: 0o700 });
await mkdir(openWikiLocalWikiDir, { recursive: true, mode: 0o700 });
await mkdir(openWikiSkillsDir, { recursive: true, mode: 0o700 });
await mkdir(openWikiStrategyDir, { recursive: true, mode: 0o700 });
}

export async function ensureStratikiHome(): Promise<void> {
Expand Down
122 changes: 122 additions & 0 deletions src/strategy/decomposer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { randomUUID } from "node:crypto";
import type { ContextIndex, ContextPacketEntry } from "../book/packet.js";
import type { Decision, DecompositionResult, Goal } from "./types.js";

/**
* Decomposes a decision into goals, grounded in the company brain context.
*
* This is a minimal implementation that:
* 1. Searches the book for relevant context
* 2. Decomposes the decision into simple goals
* 3. Ranks goals based on how well they're grounded in existing knowledge
*/
export function decomposeDecision(
decision: Decision,
bookIndex: ContextIndex,
): DecompositionResult {
const contextEntries = bookIndex.search(decision.description, 10);
const goals = extractGoalsFromDecision(decision, contextEntries);

return {
decision,
goals,
};
}

/**
* Extracts goals from a decision description and ranks them by grounding.
*/
function extractGoalsFromDecision(
decision: Decision,
contextEntries: readonly ContextPacketEntry[],
): Goal[] {
const now = new Date();
const groundingPaths = new Set(contextEntries.map((entry) => entry.path));

const rawGoals = parseGoalsFromDescription(decision.description);

return rawGoals.map((goalDesc, index) => {
const grounding = findGroundingForGoal(goalDesc, contextEntries);
const rank = calculateRank(grounding, groundingPaths, index);

return {
createdAt: now,
decisionId: decision.id,
description: goalDesc,
groundedIn: grounding,
id: randomUUID(),
rank,
status: "pending",
updatedAt: now,
};
});
}

/**
* Naive goal extraction: split by sentence boundaries or bullet points.
* In a real implementation, this would use an LLM.
*/
function parseGoalsFromDescription(description: string): string[] {
const bulletPattern = /^[-*•]\s+(.+)$/gmu;
const bullets: string[] = [];
let match;

while ((match = bulletPattern.exec(description)) !== null) {
bullets.push(match[1].trim());
}

if (bullets.length > 0) {
return bullets;
}

const sentences = description
.split(/[.!?]+/u)
.map((s) => s.trim())
.filter((s) => s.length > 0);

return sentences.slice(0, 3);
}

/**
* Finds book context paths that are relevant to a goal.
*/
function findGroundingForGoal(
goalDesc: string,
contextEntries: readonly ContextPacketEntry[],
): string[] {
const goalWords = new Set(
goalDesc
.toLowerCase()
.split(/\W+/u)
.filter((w) => w.length > 3),
);

return contextEntries
.filter((entry) => {
const entryWords = new Set(
entry.excerpt
.toLowerCase()
.split(/\W+/u)
.filter((w) => w.length > 3),
);

const commonWords = [...goalWords].filter((w) => entryWords.has(w));
return commonWords.length >= 1;
})
.map((entry) => entry.path);
}

/**
* Calculates a goal's rank based on how well it's grounded.
* Higher rank = better grounded = should be prioritized.
*/
function calculateRank(
grounding: string[],
_allPaths: Set<string>,
baseIndex: number,
): number {
const groundingScore = Math.min(grounding.length, 5) * 10;
const positionPenalty = baseIndex;

return 100 + groundingScore - positionPenalty;
}
24 changes: 24 additions & 0 deletions src/strategy/parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { randomUUID } from "node:crypto";
import type { Decision, DecisionSeedRequest } from "./types.js";

/**
* Parses a decision seed request and creates a Decision record.
*/
export function parseDecisionSeed(request: DecisionSeedRequest): Decision {
const description = request.description.trim();

if (description.length === 0) {
throw new Error("Decision description cannot be empty");
}

if (description.length > 500) {
throw new Error("Decision description must be 500 characters or less");
}

return {
createdAt: new Date(),
description,
id: randomUUID(),
status: "active",
};
}
80 changes: 80 additions & 0 deletions src/strategy/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { Decision, Goal } from "./types.js";

/**
* Persistence layer for the strategy store.
*
* For the minimal viable implementation, we store decisions and goals
* as simple JSON files in the Stratiki home directory under `strategy/`
* (typically `~/.stratiki/strategy/` or `~/.openwiki/strategy/`).
*/

export interface StrategyStore {
saveDecision(decision: Decision): Promise<void>;
saveGoals(goals: readonly Goal[]): Promise<void>;
listDecisions(): Promise<Decision[]>;
getGoalsForDecision(decisionId: string): Promise<Goal[]>;
}

export class FileStrategyStore implements StrategyStore {
constructor(private readonly strategyDir: string) {}

async saveDecision(decision: Decision): Promise<void> {
const { mkdir, writeFile } = await import("node:fs/promises");
await mkdir(this.strategyDir, { recursive: true });

const decisions = await this.listDecisions();
decisions.push(decision);

await writeFile(
`${this.strategyDir}/decisions.json`,
JSON.stringify(decisions, null, 2),
);
}

async saveGoals(goals: readonly Goal[]): Promise<void> {
const { mkdir, writeFile } = await import("node:fs/promises");
await mkdir(this.strategyDir, { recursive: true });

for (const goal of goals) {
const goalsForDecision = await this.getGoalsForDecision(goal.decisionId);
const existingIndex = goalsForDecision.findIndex((g) => g.id === goal.id);

if (existingIndex >= 0) {
goalsForDecision[existingIndex] = goal;
} else {
goalsForDecision.push(goal);
}

await writeFile(
`${this.strategyDir}/goals-${goal.decisionId}.json`,
JSON.stringify(goalsForDecision, null, 2),
);
}
}

async listDecisions(): Promise<Decision[]> {
const { readFile } = await import("node:fs/promises");
try {
const content = await readFile(
`${this.strategyDir}/decisions.json`,
"utf8",
);
return JSON.parse(content) as Decision[];
} catch {
return [];
}
}

async getGoalsForDecision(decisionId: string): Promise<Goal[]> {
const { readFile } = await import("node:fs/promises");
try {
const content = await readFile(
`${this.strategyDir}/goals-${decisionId}.json`,
"utf8",
);
return JSON.parse(content) as Goal[];
} catch {
return [];
}
}
}
Loading
Loading