refactor: CPM and estimation become process definitions, not code modules
This commit is contained in:
@@ -3,18 +3,29 @@ export * as KernelOps from "./kernel"
|
|||||||
import { Graph } from "./graph"
|
import { Graph } from "./graph"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic process execution over the graph.
|
* The unified kernel. One register function, one execute function,
|
||||||
|
* generic node queries. Every concept - tool, process, knowledge,
|
||||||
|
* artifact, conversation, memory - is a node registered here.
|
||||||
*
|
*
|
||||||
* A process is a node whose payload defines:
|
* No parallel registries. No per-concept files. One store, one truth.
|
||||||
* - name
|
|
||||||
* - input requirements (which edges to read)
|
|
||||||
* - an executable step (registered by category, resolved at runtime)
|
|
||||||
*
|
|
||||||
* The kernel does NOT know what "learn" or "build" mean. It reads the
|
|
||||||
* process node, resolves its dependencies, runs it, writes results back
|
|
||||||
* as edges. New processes are data — add a node, not a function.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export interface Node {
|
||||||
|
id: string
|
||||||
|
kind: string
|
||||||
|
address: string
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Edge {
|
||||||
|
id: string
|
||||||
|
from_node: string
|
||||||
|
to_node: string
|
||||||
|
kind: string
|
||||||
|
at: number
|
||||||
|
payload: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export class Kernel {
|
export class Kernel {
|
||||||
private graph: Graph
|
private graph: Graph
|
||||||
|
|
||||||
@@ -22,78 +33,163 @@ export class Kernel {
|
|||||||
this.graph = graph
|
this.graph = graph
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Register a process definition as a node in the graph. */
|
/**
|
||||||
defineProcess(name: string, definition: {
|
* Register anything. Kind determines what it is;
|
||||||
description: string
|
* definition edges carry the specifics.
|
||||||
/** Edges to read for input (by kind). */
|
*/
|
||||||
reads: string[]
|
register(kind: string, address: string, edges: Array<{ kind: string; payload?: unknown }>): Node {
|
||||||
/** Edge kind to write results as. */
|
const node = this.graph.ensureNode(kind, address)
|
||||||
writes: string
|
for (const e of edges) {
|
||||||
/** The actual work. Injected by whoever composes this process. */
|
this.graph.edge(node.id, node.id, e.kind, e.payload)
|
||||||
execute: (input: unknown) => Promise<unknown>
|
|
||||||
}): void {
|
|
||||||
const procNode = this.graph.ensureNode("process-definition", `process:${name}`)
|
|
||||||
const existingEdges = this.graph.incident(procNode.id, "definition")
|
|
||||||
if (existingEdges.length === 0) {
|
|
||||||
this.graph.edge(procNode.id, procNode.id, "definition", { ...definition })
|
|
||||||
}
|
}
|
||||||
|
return node
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run a named process against a target node.
|
* Execute a process node against a target node.
|
||||||
* Reads the process definition, gathers input edges, executes, writes output.
|
* Reads the process's definition edge for its handler and input kinds.
|
||||||
*/
|
*/
|
||||||
async runProcess(processName: string, targetNodeID: string): Promise<unknown> {
|
async execute(processNodeID: string, targetNodeID: string): Promise<unknown> {
|
||||||
const defEdge = this.graph
|
const defEdges = this.graph.outgoing(processNodeID, "definition")
|
||||||
.incident(this.graph.ensureNode("process-definition", `process:${processName}`).id, "definition")
|
const lastDef = defEdges.at(-1)
|
||||||
.at(-1)
|
if (!lastDef || !lastDef.payload) throw new Error(`process ${processNodeID} has no definition`)
|
||||||
if (!defEdge) throw new Error(`process "${processName}" not defined`)
|
const def = JSON.parse(lastDef.payload)
|
||||||
const def = this.graph.payload<{ description: string; reads: string[]; execute: (...args: any[]) => Promise<unknown> }>(defEdge)
|
|
||||||
if (!def) throw new Error(`process "${processName}" has no definition payload`)
|
|
||||||
|
|
||||||
// Gather inputs from the target's incident edges matching the reads list
|
// Gather inputs from target's edges matching reads list
|
||||||
const inputs: Record<string, unknown[]> = {}
|
const inputs: Record<string, unknown[]> = {}
|
||||||
for (const readKind of def.reads ?? []) {
|
for (const readKind of def.reads ?? []) {
|
||||||
inputs[readKind] = this.graph.outgoing(targetNodeID, readKind).map((e) => ({
|
inputs[readKind] = this.graph.outgoing(targetNodeID, readKind).map((e) => ({
|
||||||
edgeId: e.id,
|
kind: e.kind,
|
||||||
payload: e.payload ? JSON.parse(e.payload) : undefined,
|
payload: e.payload ? JSON.parse(e.payload) : undefined,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await def.execute(inputs)
|
const result = await def.execute(inputs)
|
||||||
|
|
||||||
// Write results back as edges on the target node
|
// Supersede prior results from same process on same target
|
||||||
if (result !== undefined && result !== null) {
|
const priorKind = "result:" + processNodeID
|
||||||
this.graph.edge(targetNodeID, targetNodeID, `result:${processName}`, { data: result })
|
const priors = this.graph.outgoing(targetNodeID).filter(function(e) {
|
||||||
|
return e.kind === priorKind
|
||||||
|
})
|
||||||
|
for (const prior of priors) {
|
||||||
|
this.graph.edge(targetNodeID, targetNodeID, "supersedes", { supersededBy: processNodeID })
|
||||||
}
|
}
|
||||||
|
this.graph.edge(targetNodeID, targetNodeID, priorKind, { data: result })
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- generic node ops ----
|
// ---- generic queries ----
|
||||||
|
|
||||||
ensure(kind: string, address: string): Node {
|
|
||||||
return this.graph.ensureNode(kind, address)
|
|
||||||
}
|
|
||||||
|
|
||||||
find(kind: string, address: string): Node | undefined {
|
|
||||||
const n = this.graph.getNode(address)
|
|
||||||
return n && n.kind === kind ? n : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
byKind(kind: string): Node[] {
|
byKind(kind: string): Node[] {
|
||||||
const db = (this.graph as any).db
|
return this.graph.nodesByKind(kind)
|
||||||
if (!db) return []
|
}
|
||||||
return db.query(`SELECT * FROM nodes WHERE kind = ? ORDER BY created_at`).all(kind) ?? []
|
|
||||||
|
find(address: string): Node | undefined {
|
||||||
|
return this.graph.getNode(address)
|
||||||
|
}
|
||||||
|
|
||||||
|
outgoing(nodeID: string, kind?: string): Edge[] {
|
||||||
|
return this.graph.outgoing(nodeID, kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
incident(nodeID: string, kind?: string): Edge[] {
|
||||||
|
return this.graph.incident(nodeID, kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
edge(from: string, to: string, kind: string, payload?: unknown) {
|
edge(from: string, to: string, kind: string, payload?: unknown) {
|
||||||
return this.graph.edge(from, to, kind, payload)
|
return this.graph.edge(from, to, kind, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- generic read/write ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read any node's content, hydrated according to its format.
|
||||||
|
* Returns metadata, payload, and the resolved schema if one exists.
|
||||||
|
*/
|
||||||
|
async read(nodeID: string): Promise<{
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
payload: unknown
|
||||||
|
format: string | undefined
|
||||||
|
schemaDefinition: string | undefined
|
||||||
|
} | null> {
|
||||||
|
const node = this.graph.getNode(nodeID)
|
||||||
|
if (!node) return null
|
||||||
|
|
||||||
|
let metadata: Record<string, unknown> = {}
|
||||||
|
let payloadRaw = ""
|
||||||
|
let format: string | undefined
|
||||||
|
|
||||||
|
for (const e of this.graph.outgoing(nodeID)) {
|
||||||
|
const p = e.payload ? JSON.parse(e.payload) : undefined
|
||||||
|
if (!p) continue
|
||||||
|
if (e.kind === "metadata") Object.assign(metadata, p)
|
||||||
|
if (e.kind === "payload") {
|
||||||
|
payloadRaw = String(p.blob ?? "")
|
||||||
|
format = p.format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let schemaDefinition: string | undefined
|
||||||
|
const conformsTo = this.graph.outgoing(nodeID, "conforms-to").at(-1)
|
||||||
|
if (conformsTo) {
|
||||||
|
// Walk supersession chain to latest schema version
|
||||||
|
let schemaNodeID = conformsTo.to_node
|
||||||
|
let supersedes = this.graph.outgoing(schemaNodeID, "supersedes")
|
||||||
|
while (supersedes.length > 0) {
|
||||||
|
schemaNodeID = supersedes[0].to_node
|
||||||
|
supersedes = this.graph.outgoing(schemaNodeID, "supersedes")
|
||||||
|
}
|
||||||
|
const payloadEdges = this.graph.outgoing(schemaNodeID, "payload")
|
||||||
|
const lastPayload = payloadEdges.at(-1)
|
||||||
|
if (lastDef(lastPayload)) {
|
||||||
|
const p2 = JSON.parse(lastPayload.payload!)
|
||||||
|
schemaDefinition = p2?.blob
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadata,
|
||||||
|
payload: hydrate(payloadRaw, format),
|
||||||
|
format,
|
||||||
|
schemaDefinition,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write content to any node. Format tells consumers how to hydrate;
|
||||||
|
* schemaAddress optionally links to a validating schema node.
|
||||||
|
*/
|
||||||
|
write(nodeID: string, opts: {
|
||||||
|
format?: string
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
payload?: string
|
||||||
|
schemaAddress?: string
|
||||||
|
}): void {
|
||||||
|
if (opts.metadata) {
|
||||||
|
this.graph.edge(nodeID, nodeID, "metadata", { ...opts.metadata })
|
||||||
|
}
|
||||||
|
if (opts.payload !== undefined) {
|
||||||
|
this.graph.edge(nodeID, nodeID, "payload", { blob: opts.payload, format: opts.format })
|
||||||
|
}
|
||||||
|
if (opts.schemaAddress) {
|
||||||
|
const schemaNode = this.graph.getNode(opts.schemaAddress)
|
||||||
|
if (schemaNode) this.graph.edge(nodeID, schemaNode.id, "conforms-to", {})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Step {
|
function lastDef(edge: import("./graph").Edge | undefined): boolean {
|
||||||
id: string
|
return edge !== undefined && edge.payload !== null && edge.payload !== ""
|
||||||
description: string
|
}
|
||||||
dependsOn: string[]
|
|
||||||
|
function hydrate(raw: string, format?: string): unknown {
|
||||||
|
if (!raw) return raw
|
||||||
|
switch (format) {
|
||||||
|
case "json":
|
||||||
|
case "schema.json":
|
||||||
|
try { return JSON.parse(raw) } catch { return raw }
|
||||||
|
default:
|
||||||
|
return raw
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,16 @@ export * as Orchestration from "./orchestration"
|
|||||||
import { Graph } from "./graph"
|
import { Graph } from "./graph"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every operation is a process. Processes are nodes in the graph.
|
* Every operation is a process node. You hold the node reference,
|
||||||
* One generic executor runs them all.
|
* pass it to the executor. No name lookup, no magic strings.
|
||||||
*
|
|
||||||
* Results are edges on the target node. New results supersede old
|
|
||||||
* results from the same process — automatically. Old results stay
|
|
||||||
* walkable forever.
|
|
||||||
*
|
|
||||||
* This covers everything: building artifacts, producing knowledge,
|
|
||||||
* refining processes, refining knowledge. They are all just
|
|
||||||
* "run process P against target T" with different definitions.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
interface GraphNode {
|
||||||
|
id: string
|
||||||
|
kind: string
|
||||||
|
address: string
|
||||||
|
}
|
||||||
|
|
||||||
export class Orchestrator {
|
export class Orchestrator {
|
||||||
private graph: Graph
|
private graph: Graph
|
||||||
|
|
||||||
@@ -22,72 +20,41 @@ export class Orchestrator {
|
|||||||
this.graph = graph
|
this.graph = graph
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Register any process definition. */
|
define(def: {
|
||||||
define(name: string, def: {
|
|
||||||
description: string
|
description: string
|
||||||
reads: string[]
|
reads: string[]
|
||||||
execute: (inputs: Record<string, unknown[]>) => Promise<unknown>
|
execute: (inputs: Record<string, unknown[]>) => Promise<unknown>
|
||||||
}): void {
|
}): GraphNode {
|
||||||
const procNode = this.graph.ensureNode("process-definition", `process:${name}`)
|
const procNode = this.graph.ensureNode("process-definition", "process:" + crypto.randomUUID())
|
||||||
if (this.graph.incident(procNode.id, "definition").length === 0) {
|
this.graph.edge(procNode.id, procNode.id, "definition", { ...def })
|
||||||
this.graph.edge(procNode.id, procNode.id, "definition", { ...def })
|
return { id: procNode.id, kind: "process-definition", address: procNode.address }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async run(processNode: GraphNode, targetNodeID: string): Promise<unknown> {
|
||||||
* Run any named process against a target node.
|
const defEdges = this.graph.outgoing(processNode.id, "definition")
|
||||||
*
|
const lastDef = defEdges.at(-1)
|
||||||
* Automatically supersedes prior results from the same process on the
|
if (!lastDef || !lastDef.payload) throw new Error("process has no definition")
|
||||||
* same target. Old results stay in the graph walkable forever.
|
const def = JSON.parse(lastDef.payload)
|
||||||
*/
|
|
||||||
async run(processName: string, targetNodeID: string): Promise<unknown> {
|
|
||||||
const procAddr = `process:${processName}`
|
|
||||||
const procNode = this.graph.getNode(procAddr)
|
|
||||||
if (!procNode) throw new Error(`process not found: ${processName}`)
|
|
||||||
|
|
||||||
// Gather inputs from target's edges matching what this process reads
|
|
||||||
const inputs: Record<string, unknown[]> = {}
|
const inputs: Record<string, unknown[]> = {}
|
||||||
for (const e of this.graph.outgoing(targetNodeID)) {
|
for (const readKind of def.reads ?? []) {
|
||||||
const p = e.payload ? JSON.parse(e.payload) : undefined
|
inputs[readKind] = this.graph.outgoing(targetNodeID, readKind).map(function(e) {
|
||||||
if (!p || typeof p !== "object") continue
|
return { kind: e.kind, payload: e.payload ? JSON.parse(e.payload) : undefined }
|
||||||
for (const readKind of Object.keys(inputs)) {
|
})
|
||||||
if (e.kind === readKind && !inputs[readKind].includes(p)) {
|
|
||||||
inputs[readKind].push(p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.executeDefinition(processName, inputs)
|
const result = await def.execute(inputs)
|
||||||
|
|
||||||
// Write result, superseding any prior result from the same process
|
// Supersede prior results from same process on same target
|
||||||
this.writeResult(targetNodeID, processName, result)
|
const priorKind = "result:" + processNode.id
|
||||||
|
const priors = this.graph.outgoing(targetNodeID).filter(function(e) {
|
||||||
|
return e.kind === priorKind
|
||||||
|
})
|
||||||
|
for (const prior of priors) {
|
||||||
|
this.graph.edge(targetNodeID, targetNodeID, "supersedes", { supersededBy: processNode.id })
|
||||||
|
}
|
||||||
|
this.graph.edge(targetNodeID, targetNodeID, priorKind, { data: result })
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private writeResult(targetNodeID: string, processName: string, result: unknown): void {
|
|
||||||
// Supersede prior result from same process on same target
|
|
||||||
const priorKind = `result:${processName}`
|
|
||||||
const priors = this.graph.outgoing(targetNodeID, priorKind)
|
|
||||||
for (const prior of priors) {
|
|
||||||
this.graph.edge(prior.to_node ?? prior.from_node, prior.from_node, "supersedes",
|
|
||||||
{ process: processName, supersededAt: Date.now() })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write new result
|
|
||||||
this.graph.edge(targetNodeID, targetNodeID, priorKind, { data: result })
|
|
||||||
}
|
|
||||||
|
|
||||||
private async executeDefinition(processName: string, inputs: Record<string, unknown[]>): Promise<unknown> {
|
|
||||||
const procAddr = `process:${processName}`
|
|
||||||
const procNode = this.graph.getNode(procAddr)
|
|
||||||
if (!procNode) throw new Error(`process not found: ${procAddr}`)
|
|
||||||
|
|
||||||
const defEdges = this.graph.incident(procNode.id, "definition")
|
|
||||||
const lastDef = defEdges.at(-1)
|
|
||||||
if (!lastDef?.payload) throw new Error(`no definition for process ${processName}`)
|
|
||||||
const def = JSON.parse(lastDef.payload)
|
|
||||||
if (!def.handler) throw new Error(`process ${processName} has no handler`)
|
|
||||||
return def.handler(inputs)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
export * as StepExecution from "./step-execution"
|
||||||
|
|
||||||
|
import { Graph } from "./graph"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A step execution is not just "did the thing."
|
||||||
|
*
|
||||||
|
* Every step records:
|
||||||
|
* - which knowledge nodes were consulted (used-knowledge edges)
|
||||||
|
* - which process definition was followed, or explicitly none
|
||||||
|
* - what lessons were learned during execution
|
||||||
|
*
|
||||||
|
* These are the edges that make LEARN possible: without provenance of
|
||||||
|
* what informed each step, you cannot refine anything.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface StepInput {
|
||||||
|
stepNodeID: string
|
||||||
|
/** Knowledge nodes consulted during this step. */
|
||||||
|
knowledgeUsed: string[]
|
||||||
|
/** Process definition followed, or null if none matched. */
|
||||||
|
processUsed: string | null
|
||||||
|
/** What was learned doing this work. */
|
||||||
|
lesson?: string
|
||||||
|
/** The actual output. */
|
||||||
|
result: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function execute(graph: Graph, sessionID: string, input: StepInput): { resultEdgeID: string } {
|
||||||
|
const turn = graph.ensureNode("turn", `turn:${crypto.randomUUID()}`)
|
||||||
|
graph.edge(sessionID === turn.id ? turn.id : turn.id, turn.id, "self", {})
|
||||||
|
|
||||||
|
// Wire knowledge provenance
|
||||||
|
for (const knID of input.knowledgeUsed) {
|
||||||
|
graph.edge(turn.id, knID, "used-knowledge", {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire process provenance (or explicit absence)
|
||||||
|
if (input.processUsed) {
|
||||||
|
const procNode = graph.getNode(`process:${input.processUsed}`)
|
||||||
|
if (procNode) {
|
||||||
|
graph.edge(turn.id, procNode.id, "used-process", {})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
graph.edge(turn.id, turn.id, "no-process-used", { explicit: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the result
|
||||||
|
graph.edge(turn.id, turn.id, "result", { text: input.result })
|
||||||
|
|
||||||
|
return { resultEdgeID: turn.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract lessons from completed steps — this feeds the LEARN phase. */
|
||||||
|
export function collectLessons(graph: Graph, convNodeId: string): Array<{ lesson: string; hadProcess: boolean; knowledgeUsed: number }> {
|
||||||
|
const lessons: Array<{ lesson: string; hadProcess: boolean; knowledgeUsed: number }> = []
|
||||||
|
const turnEdges = graph.outgoing(convNodeId, "turn")
|
||||||
|
|
||||||
|
for (const e of turnEdges) {
|
||||||
|
const usedKnowledge = graph.outgoing(e.to_node, "used-knowledge")
|
||||||
|
const usedProcess = graph.outgoing(e.to_node, "used-process")
|
||||||
|
const noProcess = graph.outgoing(e.to_node, "no-process-used")
|
||||||
|
const results = graph.outgoing(e.to_node, "result")
|
||||||
|
|
||||||
|
if (results.length > 0 && !usedProcess.length && noProcess.length > 0) {
|
||||||
|
const payload = results[0]?.payload ? JSON.parse(results[0].payload).text : ""
|
||||||
|
lessons.push({ lesson: payload, hadProcess: false, knowledgeUsed: usedKnowledge.length })
|
||||||
|
} else if (results.length > 0 && usedProcess.length > 0) {
|
||||||
|
const payload = results[0]?.payload ? JSON.parse(results[0].payload).text : ""
|
||||||
|
lessons.push({ lesson: payload, hadProcess: true, knowledgeUsed: usedKnowledge.length })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lessons
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
export * as ToolExecutor from "./tool-executor"
|
||||||
|
|
||||||
|
import { Graph } from "./graph"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic tool executor. Reads the tool node's edges, dispatches to the
|
||||||
|
* right primitive. Adding a new tool = adding a graph node with the
|
||||||
|
* right edges. No code changes.
|
||||||
|
*
|
||||||
|
* Primitives are fixed and small:
|
||||||
|
* bash - spawn shell command
|
||||||
|
* read - read file
|
||||||
|
* write - write file
|
||||||
|
* fetch - HTTP request
|
||||||
|
* search - ripgrep
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ToolSpec {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
scope: string
|
||||||
|
parameters: unknown
|
||||||
|
primitive: string
|
||||||
|
config?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(graph: Graph, opts: ToolSpec): void {
|
||||||
|
const addr = `tool:${opts.name}`
|
||||||
|
const node = graph.ensureNode("tool", addr)
|
||||||
|
graph.edge(node.id, node.id, "description", { text: opts.description })
|
||||||
|
graph.edge(node.id, node.id, "parameters", { schema: opts.parameters })
|
||||||
|
graph.edge(node.id, node.id, "scope", { value: opts.scope })
|
||||||
|
graph.edge(node.id, node.id, "implementation", { primitive: opts.primitive, config: opts.config ?? {} })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function execute(
|
||||||
|
graph: Graph,
|
||||||
|
toolName: string,
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
ctx: { cwd: string },
|
||||||
|
): Promise<string> {
|
||||||
|
const addr = `tool:${toolName}`
|
||||||
|
const node = graph.getNode(addr)
|
||||||
|
if (!node) return Promise.reject(new Error(`unknown tool: ${toolName}`))
|
||||||
|
|
||||||
|
const implEdges = graph.outgoing(node.id, "implementation")
|
||||||
|
const implEdge = implEdges.at(-1)
|
||||||
|
if (!implEdge || !implEdge.payload) return Promise.reject(new Error(`tool ${toolName} has no implementation`))
|
||||||
|
|
||||||
|
const impl = JSON.parse(implEdge.payload) as { primitive: string; config?: Record<string, unknown> }
|
||||||
|
const config = impl.config ?? {}
|
||||||
|
|
||||||
|
switch (impl.primitive) {
|
||||||
|
case "bash": {
|
||||||
|
const cmd = String(input.command ?? "")
|
||||||
|
const workDir = String(config.cwd ?? ctx.cwd)
|
||||||
|
const proc = Bun.spawn(["zsh", "-c", cmd], { cwd: workDir, stdout: "pipe", stderr: "pipe" })
|
||||||
|
return proc.exited.then(function(code) {
|
||||||
|
const out = new Response(proc.stdout).body ? "" : ""
|
||||||
|
return new Response(proc.stdout).text().then(function(stdout) {
|
||||||
|
const stderrText = new Response(proc.stderr).text()
|
||||||
|
return stdout + (code === 0 ? "" : "\n[exit " + code + "]")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case "read": {
|
||||||
|
const filePath = String(input.path ?? "")
|
||||||
|
const limit = typeof input.limit === "number" ? input.limit : undefined
|
||||||
|
return Bun.file(ctx.cwd + "/" + filePath)
|
||||||
|
.text()
|
||||||
|
.then(function(text) {
|
||||||
|
if (!limit) return text
|
||||||
|
return text.split("\n").slice(0, limit).join("\n")
|
||||||
|
})
|
||||||
|
.catch(function() { return "[file not found: " + filePath + "]" })
|
||||||
|
}
|
||||||
|
case "write": {
|
||||||
|
const filePath = String(input.path ?? "")
|
||||||
|
const content = String(input.content ?? "")
|
||||||
|
return Bun.write(ctx.cwd + "/" + filePath, content)
|
||||||
|
.then(function() { return "wrote " + filePath + " (" + content.length + " bytes)" })
|
||||||
|
}
|
||||||
|
case "fetch": {
|
||||||
|
const url = String(config.url ?? input.url ?? "")
|
||||||
|
return fetch(url).then(function(r) { return r.text() })
|
||||||
|
}
|
||||||
|
case "search": {
|
||||||
|
const pattern = String(input.pattern ?? "")
|
||||||
|
const searchDir = String(config.dir ?? ctx.cwd)
|
||||||
|
const proc = Bun.spawn(["rg", pattern, "--line-number", "--max-count", "20"], { cwd: searchDir, stdout: "pipe", stderr: "pipe" })
|
||||||
|
return proc.exited.then(function() {
|
||||||
|
return new Response(proc.stdout).text()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return Promise.reject(new Error(`unknown primitive: ${impl.primitive}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
export * as ToolNodes from "./tool-nodes"
|
||||||
|
|
||||||
|
import { Graph } from "./graph"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tools are typed nodes in the graph.
|
||||||
|
* Definition edges carry description, parameters, scope, handler.
|
||||||
|
* No separate registry, no config file — query the graph.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function register(graph: Graph, opts: {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
parameters: unknown
|
||||||
|
scope: string
|
||||||
|
scopeNodeID?: string
|
||||||
|
run: (input: any) => Promise<string>
|
||||||
|
}): { nodeID: string } {
|
||||||
|
const addr = `tool:${opts.name}`
|
||||||
|
const node = graph.ensureNode("tool", addr)
|
||||||
|
|
||||||
|
graph.edge(node.id, node.id, "description", { text: opts.description })
|
||||||
|
graph.edge(node.id, node.id, "parameters", { schema: opts.parameters })
|
||||||
|
graph.edge(node.id, node.id, "scope", { value: opts.scope })
|
||||||
|
graph.edge(node.id, node.id, "handler", { fn: opts.run })
|
||||||
|
|
||||||
|
if (opts.scopeNodeID) {
|
||||||
|
graph.edge(opts.scopeNodeID, node.id, "provides-tool", {})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nodeID: node.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lookup(graph: Graph, name: string): { nodeID: string; run: (input: any) => Promise<string>; scope: string } | undefined {
|
||||||
|
const node = graph.getNode(`tool:${name}`)
|
||||||
|
if (!node) return undefined
|
||||||
|
|
||||||
|
let handler: ((input: any) => Promise<string>) | undefined
|
||||||
|
let scope = ""
|
||||||
|
|
||||||
|
for (const e of graph.outgoing(node.id)) {
|
||||||
|
if (e.kind === "handler") {
|
||||||
|
const p = e.payload ? JSON.parse(e.payload) : undefined
|
||||||
|
if (p?.fn) handler = p.fn
|
||||||
|
}
|
||||||
|
if (e.kind === "scope") {
|
||||||
|
const p = e.payload ? JSON.parse(e.payload) : undefined
|
||||||
|
if (p?.value) scope = p.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!handler) return undefined
|
||||||
|
return { nodeID: node.id, run: handler, scope }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allTools(graph: Graph): Array<{ name: string }> {
|
||||||
|
return graph.nodesByKind("tool").map((n) => ({
|
||||||
|
name: n.address.replace("tool:", ""),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Registers the existing tool inventory as graph nodes.
|
||||||
|
* Each tool becomes a node with edges for description/parameters/scope/primitive.
|
||||||
|
* The old tools.ts stays untouched for reference — this is the graph-native version.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Graph } from "./graph"
|
||||||
|
import { register } from "./tool-executor"
|
||||||
|
|
||||||
|
export function registerBuiltinTools(graph: Graph, cwd: string): void {
|
||||||
|
register(graph, {
|
||||||
|
name: "bash",
|
||||||
|
description: "Run a shell command in the working directory and return its output.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: { command: { type: "string" } },
|
||||||
|
required: ["command"],
|
||||||
|
},
|
||||||
|
scope: "fs.execute",
|
||||||
|
primitive: "bash",
|
||||||
|
})
|
||||||
|
|
||||||
|
register(graph, {
|
||||||
|
name: "read",
|
||||||
|
description: "Read a text file relative to the working directory.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
path: { type: "string", description: "File path relative to working directory" },
|
||||||
|
limit: { type: "number", description: "Max lines to read" },
|
||||||
|
},
|
||||||
|
required: ["path"],
|
||||||
|
},
|
||||||
|
scope: "fs.read",
|
||||||
|
primitive: "read",
|
||||||
|
})
|
||||||
|
|
||||||
|
register(graph, {
|
||||||
|
name: "write",
|
||||||
|
description: "Write content to a text file relative to the working directory.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
path: { type: "string", description: "File path relative to working directory" },
|
||||||
|
content: { type: "string", description: "Content to write" },
|
||||||
|
},
|
||||||
|
required: ["path", "content"],
|
||||||
|
},
|
||||||
|
scope: "fs.write",
|
||||||
|
primitive: "write",
|
||||||
|
})
|
||||||
|
|
||||||
|
register(graph, {
|
||||||
|
name: "search",
|
||||||
|
description: "Search file contents using ripgrep. Returns matching lines with file paths and line numbers.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
pattern: { type: "string", description: "Regex pattern to search for" },
|
||||||
|
dir: { type: "string", description: "Directory to search in (defaults to cwd)" },
|
||||||
|
},
|
||||||
|
required: ["pattern"],
|
||||||
|
},
|
||||||
|
scope: "fs.read",
|
||||||
|
primitive: "search",
|
||||||
|
})
|
||||||
|
|
||||||
|
register(graph, {
|
||||||
|
name: "fetch",
|
||||||
|
description: "Fetch content from a URL and return the response body as text.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
url: { type: "string", description: "URL to fetch" },
|
||||||
|
},
|
||||||
|
required: ["url"],
|
||||||
|
},
|
||||||
|
scope: "net.fetch",
|
||||||
|
primitive: "fetch",
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user