{"version":3,"file":"outline.mjs","names":[],"sources":["../../../src/batteries/orchestration/outline.ts"],"sourcesContent":["/**\n * @module @nhtio/adk/batteries/orchestration/outline\n *\n * Progressive-disclosure reading for plans that are too large for a model's context window.\n * `planOutline` produces a single flat, self-describing index of a plan; `planRead` fetches a\n * small, self-locating slice of that plan by the exact identifiers the outline printed.\n *\n * @remarks\n * A model's context window is smaller than a plan will get, so it must be able to work on a plan\n * it cannot hold. This module is that mechanism, and its shape is load-bearing:\n *\n * - **ONE FLAT LEVEL. Never two.** `planOutline` returns a single flat list of phases — no\n *   sub-phases, no index that needs an index. This is not a style preference: a controlled study\n *   of this pattern found one routing level helps and a second \"never helps and sometimes breaks\n *   accuracy outright\" (0.9126 → 0.6398 on one cell), because in a two-level pack every child\n *   description sits in context before the router commits, recreating the very pressure\n *   progressive disclosure exists to relieve.\n * - **Entries carry EXACT SURFACE FORMS, not paraphrase.** Per the same study, per-chunk metadata\n *   must be a short summary PLUS a list of key elements, because the element list supplies \"exact\n *   surface forms that a one-sentence summary would paraphrase away\". For a plan that is decisive:\n *   a model writing `NodeRef{node:'archive_files'}` needs the EXACT node id, and a prose summary\n *   of a phase destroys it. So each `PhaseEntry` carries, verbatim: the phase name, the node ids,\n *   the tool name of each `call` node in that phase, an open-issue count, and a one-line summary.\n * - **`unphased` is addressed identically.** Nodes with no `phase` are not second-class — they get\n *   their own `PhaseEntry` so a model reaches them the same way.\n * - **The outline's key IS the reader's key.** `planRead` takes the SAME identifiers the outline\n *   printed — a phase name or a node id. No line numbers anywhere.\n * - **Each slice is SELF-LOCATING.** A returned slice carries its phase and the immediate\n *   predecessors/successors of the slice as a whole (`boundary`), so a model can keep linking new\n *   nodes without re-fetching the outline.\n * - **Scoped reading is available, not mandatory.** The study's own conclusion is that progressive\n *   disclosure \"buys context, not intelligence\" — decisive once an artifact is too large to read,\n *   redundant when an agent can navigate it directly. So a five-node plan should be read whole;\n *   the outline hop is not compulsory.\n */\n\nimport { foldOps } from './ops'\nimport { PlanStore } from './store'\nimport { isObject } from '../../lib/utils/guards'\nimport { incoming, outgoing, nodeById } from './plan'\nimport type {\n  NodeId,\n  PhaseEntry,\n  PlanIssue,\n  PlanNode,\n  PlanOutline,\n  PlanSlice,\n  RawPlanView,\n} from './types'\n\n/**\n * Build a flat outline of a plan: one entry per phase, plus a single entry for unphased nodes.\n *\n * @remarks\n * Each `PhaseEntry` carries the exact surface forms a reader needs to fetch a slice — the phase\n * name, the node ids, the tool names of every `call` node, an open-issue count, and a one-line\n * summary. The outline is deliberately a single flat list with no second routing level; see the\n * module doc for why.\n *\n * @param store - the plan store to read from.\n * @param planId - the id of the plan to outline.\n * @returns the flat outline of the plan.\n */\nexport async function planOutline(store: PlanStore, planId: string): Promise<PlanOutline> {\n  const state = await store.readState(planId)\n  const ops = await store.readOps(planId)\n  const { view, issues } = foldOps(planId, ops)\n\n  const byPhase = new Map<string, PlanNode[]>()\n  const unphased: PlanNode[] = []\n\n  for (const node of view.nodes) {\n    const phase = node.phase ?? ''\n    if (phase === '') {\n      unphased.push(node)\n    } else {\n      const bucket = byPhase.get(phase)\n      if (bucket === undefined) byPhase.set(phase, [node])\n      else bucket.push(node)\n    }\n  }\n\n  const phases: PhaseEntry[] = []\n  for (const [phase, phaseNodes] of byPhase) {\n    phases.push(entryFor(phase, phaseNodes, issues))\n  }\n\n  return {\n    planId,\n    state: state.state,\n    digest: state.digest,\n    nodeCount: view.nodes.length,\n    phases,\n    unphased: unphased.length > 0 ? entryFor('', unphased, issues) : undefined,\n  }\n}\n\n/**\n * Read a self-locating slice of a plan by the exact identifier the outline printed.\n *\n * @remarks\n * The selection is either a phase name (`{ phase }`) or a node id (`{ node }`) — the SAME\n * identifiers the outline printed, never line numbers. The returned slice carries its phase and\n * the immediate predecessors/successors of the slice as a whole (`boundary`), so a model can keep\n * linking new nodes without re-fetching the outline.\n *\n * Scoped reading is available, not mandatory: a small plan should be read whole, and the outline\n * hop is not compulsory.\n *\n * @param store - the plan store to read from.\n * @param planId - the id of the plan to read.\n * @param sel - the selection: `{ phase }` to read a whole phase, or `{ node }` to read the slice\n *   around a single node.\n * @returns the self-locating slice of the plan.\n * @throws if the phase or node id is unknown, naming the valid set — never an empty slice.\n */\nexport async function planRead(\n  store: PlanStore,\n  planId: string,\n  sel: { phase: string } | { node: NodeId }\n): Promise<PlanSlice> {\n  if (!isObject(sel)) {\n    throw new Error('planRead: selection must be an object')\n  }\n\n  const ops = await store.readOps(planId)\n  const { view, issues } = foldOps(planId, ops)\n\n  const allIds = view.nodes.map((n) => n.id)\n\n  let selected: PlanNode[]\n  let phase: string | undefined\n\n  if ('phase' in sel) {\n    const wanted = sel.phase\n    selected = view.nodes.filter((n) => (n.phase ?? '') === wanted)\n    if (selected.length === 0) {\n      throw new Error(\n        `planRead: unknown phase ${JSON.stringify(wanted)}; valid phases are ${JSON.stringify([\n          ...new Set(view.nodes.map((n) => n.phase ?? '')),\n        ])}`\n      )\n    }\n    phase = wanted === '' ? undefined : wanted\n  } else {\n    const wanted = sel.node\n    const node = nodeById(view, wanted)\n    if (node === undefined) {\n      throw new Error(\n        `planRead: unknown node ${JSON.stringify(wanted)}; valid node ids are ${JSON.stringify(\n          allIds\n        )}`\n      )\n    }\n    selected = [node]\n    phase = node.phase\n  }\n\n  const selectedIds = new Set(selected.map((n) => n.id))\n  const boundary =\n    selected.length === 1\n      ? {\n          incoming: incoming(view, selected[0].id).map((e) => e.from),\n          outgoing: outgoing(view, selected[0].id).map((e) => e.to),\n        }\n      : boundaryOf(view, selectedIds)\n\n  return {\n    nodes: selected,\n    phase,\n    boundary,\n    issues,\n  }\n}\n\n/**\n * Build a single `PhaseEntry` for a group of nodes sharing a phase.\n *\n * @param phase - the phase name (empty string for unphased nodes).\n * @param nodes - the nodes in the phase.\n * @param issues - the plan's issues, used to count open issues.\n * @returns the phase entry.\n */\nfunction entryFor(phase: string, nodes: PlanNode[], issues: PlanIssue[]): PhaseEntry {\n  const nodeIds = nodes.map((n) => n.id)\n  const tools = nodes.filter((n) => n.kind === 'call').map((n) => n.definition.tool)\n\n  const issueCount = issues.filter(\n    (issue) => issue.nodeId !== undefined && nodeIds.includes(issue.nodeId)\n  ).length\n\n  return {\n    phase,\n    summary: summarize(phase, nodes),\n    nodeIds,\n    tools,\n    issueCount,\n  }\n}\n\n/**\n * Produce a one-line summary of a group of nodes.\n *\n * @param phase - the phase name.\n * @param nodes - the nodes in the phase.\n * @returns a short human-readable summary.\n */\nfunction summarize(phase: string, nodes: PlanNode[]): string {\n  const label = phase === '' ? 'unphased' : phase\n  const calls = nodes.filter((n) => n.kind === 'call').length\n  const reads = nodes.filter((n) => n.kind === 'reason').length\n  const writes = nodes.filter((n) => n.kind === 'transform').length\n  return `${label}: ${nodes.length} node(s) — ${calls} call, ${reads} reason, ${writes} transform`\n}\n\n/**\n * Compute the immediate predecessors/successors of a set of nodes, by id.\n *\n * @param view - the graph to search.\n * @param selectedIds - the ids of the slice's nodes.\n * @returns the boundary node ids, deduplicated.\n */\nfunction boundaryOf(\n  view: RawPlanView,\n  selectedIds: Set<NodeId>\n): { incoming: NodeId[]; outgoing: NodeId[] } {\n  const incomingIds = new Set<NodeId>()\n  const outgoingIds = new Set<NodeId>()\n  for (const edge of view.edges) {\n    if (selectedIds.has(edge.to) && !selectedIds.has(edge.from)) incomingIds.add(edge.from)\n    if (selectedIds.has(edge.from) && !selectedIds.has(edge.to)) outgoingIds.add(edge.to)\n  }\n  return { incoming: [...incomingIds], outgoing: [...outgoingIds] }\n}\n"],"mappings":"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"}