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
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import path from 'node:path'

import { keys } from './graph-keys.js'
import { commandStringFrom, programFrom, skillFromCodexRead, skillFromMarker, skillFromSlash, skillFromToolArgs, literalActions, shellReadPaths, patchPaths, skillFromPath, MAX_ACTION_CHARS } from './tool_facets.js'
import { commandStringFrom, programFrom, skillFromCodexRead, skillFromMarker, skillFromSlash, skillFromToolArgs, literalActions, shellFileEvidence, patchPaths, skillFromPath, MAX_ACTION_CHARS } from './tool_facets.js'

/**
* @import { ContractRule, GraphKit, GraphKeys } from './types.js'
Expand All @@ -23,7 +23,7 @@ export const PROJECTOR = 'ai-gateway.t0'
* `invoked` rules (LLP 0073 §additive-no-migration): provenance only, existing
* rows and ids are untouched, there is no re-key and therefore no migration.
*/
export const PROJECTOR_VERSION = 3
export const PROJECTOR_VERSION = 4

/** Tools whose args name a concrete file. */
const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
Expand Down Expand Up @@ -459,7 +459,10 @@ export function createAiGatewayGraphContract(kit) {
if (row === lastRow) return lastActions
const wrapped = row.tool_name === 'exec' || row.tool_name === 'functions.exec'
const inputs = wrapped ? literalActions(row.tool_args) : [row]
lastActions = inputs.map(action => ({ ...row, ...action, inferred: wrapped }))
lastActions = inputs.map(action => {
const input = { ...row, ...action, inferred: wrapped }
return { ...input, shellFiles: shellFileEvidence(commandStringFrom(input.tool_name, input.tool_args)) }
})
lastRow = row
return lastActions
}
Expand All @@ -474,11 +477,11 @@ export function createAiGatewayGraphContract(kit) {
rule.toRows = (row) => {
const results = []
for (const input of actionRule ? actionsFor(row) : [row]) {
const action = input.tool_name ? input : { ...input, tool_name: defaultTool }
const action = input.tool_name ? input : { ...input, tool_name: defaultTool, shellFiles: shellFileEvidence(commandStringFrom(defaultTool, input.tool_args)) }
let variants = [action]
if (rule.type === 'File' || rule.type === 'touched') {
let files = []
if (action.tool_name === 'exec_command' || action.tool_name === 'Bash') files = shellReadPaths(commandStringFrom(action.tool_name, action.tool_args))
if (action.tool_name === 'exec_command' || action.tool_name === 'Bash') files = action.shellFiles.map(file => file.path)
else if (action.tool_name === 'apply_patch') files = patchPaths(action.tool_args)
else {
const file = filePathFrom(action.tool_name, action.tool_args)
Expand All @@ -492,13 +495,18 @@ export function createAiGatewayGraphContract(kit) {
})
} else if (actionRule && (rule.type === 'Skill' || rule.type === 'ran')) {
if (action.tool_name !== 'exec_command') continue
variants = shellReadPaths(commandStringFrom(action.tool_name, action.tool_args))
.filter(file => skillFromPath(file))
.map(file => ({ ...action, tool_args: { cmd: `cat '${file}'` } }))
variants = action.shellFiles
.filter(file => skillFromPath(file.path))
.map(file => ({ ...action, skillOperation: file.operation, tool_args: { cmd: `cat '${file.path}'` } }))
}
for (const variant of variants) {
const built = original(variant)
if (!built) continue
// @ref LLP 0564#skill-evidence: legacy read flags may actually be searches.
if (rule.type === 'ran' && variant.skillOperation) {
built.props = variant.skillOperation === 'search'
? { dispatch_shell_search: true } : { dispatch_file_read: true }
}
built.source_keys = { .../** @type {Record<string, unknown>} */ (built.source_keys), ...pruned({ session_id: row.message_id || row.part_id ? row.session_id : undefined, message_id: row.message_id, part_id: row.part_id }) }
if (action.inferred) {
built.props = { .../** @type {Record<string, unknown>} */ (built.props), inferred_call: true }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,11 @@ export function literalActions(source) {
* @returns {string[]}
*/
export function shellReadPaths(command) {
return shellFileEvidence(command).map(item => item.path)
}

/** @param {unknown} command @returns {{ path: string, operation: string }[]} */
export function shellFileEvidence(command) {
if (typeof command !== 'string' || command.length > MAX_ACTION_CHARS || /[\0\n\r$`|;&<>()*?{}\\]/.test(command)) return []
const words = []
let end = 0
Expand Down Expand Up @@ -659,7 +664,7 @@ export function shellReadPaths(command) {
}
}
if (tokens.length > MAX_ACTIONS || tokens.some(t => !t || t.startsWith('-') || t.length > 4096)) return []
return tokens
return tokens.map(file => ({ path: file, operation: prog === 'rg' || prog === 'grep' ? 'search' : 'read' }))
}

/** @param {unknown} file @returns {string | null} */
Expand Down
23 changes: 19 additions & 4 deletions hypaware-core/plugins-workspace/context-graph/src/maintenance.js
Original file line number Diff line number Diff line change
Expand Up @@ -422,10 +422,11 @@ function compareDupRows(a, b) {
* @param {'node' | 'edge'} dataset
* @param {GraphRow[]} rows
* @param {AbortSignal} [signal] once aborted, no further partition rewrite starts
* @param {boolean} [mergeEvidence] preserve existing evidence during partial projection
* @returns {Promise<void>}
* @ref LLP 0428#refresh [implements]: explicit refresh reuses conditional generation replacement.
*/
export async function refreshGraphRows(storage, dataset, rows, signal) {
export async function refreshGraphRows(storage, dataset, rows, signal, mergeEvidence = false) {
const idCol = ID_COLUMNS[dataset]
const byId = new Map(rows.map(row => [row[idCol], row]))
const parts = await discoverCachePartitions(storage.cacheRoot, { datasets: [dataset] })
Expand All @@ -437,13 +438,27 @@ export async function refreshGraphRows(storage, dataset, rows, signal) {
if (!tableExists(tableDir)) continue
const replacements = new Map()
const foreign = new Set()
const folded = new Map()
for await (const existing of scanRowsFromTable(tableDir)) {
const fresh = byId.get(existing[idCol])
signal?.throwIfAborted()
let fresh = byId.get(existing[idCol])
if (!fresh) continue
const id = String(existing[idCol])
if (fresh.projector !== existing.projector || fresh.source_dataset !== existing.source_dataset) {
foreign.add(String(existing[idCol]))
foreign.add(id)
continue
}
if (mergeEvidence) {
// @ref LLP 0564#incremental-evidence: partial scans add evidence, never retract it.
const merged = { ...existing }
for (const key of ['props', 'source_keys']) {
if (typeof merged[key] === 'string') merged[key] = JSON.parse(merged[key])
}
// Copies awaiting compaction are all dropped below, so fold each into one row.
mergeRow(merged, folded.get(id) ?? fresh)
folded.set(id, merged)
fresh = merged
}
// Avoid a generation rewrite when this projection is already current.
const same = Object.keys(fresh).every(key => {
let value = existing[key]
Expand All @@ -453,7 +468,7 @@ export async function refreshGraphRows(storage, dataset, rows, signal) {
return isDeepStrictEqual(value, fresh[key])
|| (key === 'first_seen' && firstSeenTime(value) === firstSeenTime(fresh[key]))
})
if (!same) replacements.set(String(existing[idCol]), { ...existing, ...fresh })
if (!same || replacements.has(id)) replacements.set(id, { ...existing, ...fresh })
}
for (const id of foreign) {
if (replacements.has(id)) throw new Error('graph refresh: shared projector identity requires a full graph rebuild')
Expand Down
9 changes: 9 additions & 0 deletions hypaware-core/plugins-workspace/context-graph/src/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,15 @@ export async function projectGraph({ query, storage, contracts, config, dryRun =
const freshNodes = await dedupExisting(nodeRows, 'node_id', NODE_DATASET, query, storage, config, maxHeapBytes, __executeSql)
const freshEdges = await dedupExisting(edgeRows, 'edge_id', EDGE_DATASET, query, storage, config, maxHeapBytes, __executeSql)

if (!refresh) {
const freshIds = new Set(freshEdges.filter(row => row.edge_type === 'ran' && row.dst_type === 'Skill').map(row => row.edge_id))
const skillUpdates = edgeRows.filter(row => row.edge_type === 'ran' && row.dst_type === 'Skill' && !freshIds.has(row.edge_id))
if (skillUpdates.length) {
const { refreshGraphRows } = await import('./maintenance.js')
await refreshGraphRows(storage, EDGE_DATASET, skillUpdates, signal, true)
}
}

// No write starts once aborted; an append already under way settles
signal?.throwIfAborted()
if (freshNodes.length > 0) {
Expand Down
21 changes: 20 additions & 1 deletion hypaware-core/plugins-workspace/context-graph/src/verb.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export const graphNeighborsVerb = {
// @ref LLP 0214#d1 [implements]: a verb explains its own flags instead of a skill narrating them
help: [
'Walks outward from a seed node and prints what it reaches.',
'Skill ran edges are evidence, not execution counts. dispatch_tool or',
'dispatch_slash means invocation requested; dispatch_marker means instructions',
'supplied; dispatch_file_read and dispatch_shell_search mean file access',
'requested. Legacy dispatch_shell_read is unverified read/search evidence.',
'No flag proves success. Use source messages for event counts and outcomes.',
'',
'The seed resolves in order: node_id, then natural_key, then label. So a',
'session id, a file path or basename, a model id, or a tool / skill /',
Expand Down Expand Up @@ -204,13 +209,15 @@ function renderNeighbors(result) {
}
}
for (const n of result.neighbors) {
const arrow = n.direction === 'out' ? `-${n.edge_type}→` : `←${n.edge_type}-`
const relation = n.edge_type === 'ran' ? 'skill evidence' : n.edge_type
const arrow = n.direction === 'out' ? `-${relation}→` : `←${relation}-`
const text = display(n.node)
let shown = text
if (labelCounts.get(text) > 1) {
shown = `${text} (${disambiguator(n.node)})`
if (shownCounts.get(shown) > 1) shown = `${text} (${n.node.node_id})`
}
if (n.edge_type === 'ran') shown += ` (${skillEvidenceLabel(n.props)})`
if (n.props?.inferred_call === true) shown += ' (inferred call)'
out.push(` ${n.hop} ${arrow.padEnd(18)} ${n.node.node_type.padEnd(8)} ${shown}`)
}
Expand Down Expand Up @@ -255,3 +262,15 @@ function disambiguator(node) {
function shortId(id) {
return id.length > 12 ? id.slice(0, 12) : id
}

/** @param {Record<string, unknown> | null | undefined} props @returns {string} */
function skillEvidenceLabel(props) {
const labels = []
if (props?.dispatch_tool === true || props?.dispatch_slash === true) labels.push('invocation requested')
if (props?.dispatch_marker === true) labels.push('instructions supplied')
if (props?.dispatch_file_read === true) labels.push('file read requested')
if (props?.dispatch_shell_search === true) labels.push('file search requested')
if (props?.dispatch_shell_read === true) labels.push('legacy file reference: read/search unverified')
if (!labels.length) labels.push('unclassified reference')
return labels.join('; ') + '; execution and success not established'
}
2 changes: 2 additions & 0 deletions hypaware-core/skills/hypaware-query/graph.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Prefer the graph for skill and program inventories, identities, and relationship

Read `hyp graph --help` and `hyp query graph neighbors --help` for flags. Nodes use `node_type`, `natural_key`, and `label`; edges use `edge_type`, `src_type`, and `dst_type`.

For Skill edges, `ran` means evidence of a skill relationship, not successful execution. Filter `props.dispatch_tool` or `props.dispatch_slash` for explicit invocation requests. `dispatch_marker` means instructions supplied; `dispatch_file_read` and `dispatch_shell_search` mean file access requested. Legacy `dispatch_shell_read` cannot distinguish reading from searching. Use source messages to verify execution, success, or event counts. Correct historical labels with an explicitly scoped full-source `hyp graph project --refresh`; incremental projection preserves legacy evidence.

Count distinct sessions using `count(distinct src_id)` on session edges: `used` (tool), `used_model` (model), `touched` (file), `ran` (skill), `invoked` (program), `via` (app), `in` (repo), and `at` (commit).

Use the graph to find relevant sessions, then read their messages. A Session node's `natural_key` is the message dataset's `session_id`.
Expand Down
2 changes: 2 additions & 0 deletions llp/0023-context-graph-projection.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ ordering; the same inputs must produce the same merged row regardless of scan
order, or re-projection and compaction would disagree. Projection-time and
compaction-time merging share the same function for the same reason.

**Extended-by:** [LLP 0564](./0564-skill-evidence-updates.spec.md#incremental-evidence) permits incremental merges for existing skill relationships.

## Pre-write dedup

`graph project` reads the committed id set (`SELECT node_id FROM node`) and
Expand Down
2 changes: 1 addition & 1 deletion llp/0428-graph-evidence-links.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
**Systems:** Graph, Plugins
**Date:** 2026-09-21
**Related:** LLP 0023, LLP 0028, LLP 0064, LLP 0075, LLP 0076
**Extended-by:** LLP 0431 (query cost scoped to traversed neighborhoods)
**Extended-by:** LLP 0431 (query cost scoped to traversed neighborhoods), LLP 0564 (incremental skill evidence)

## Literal actions

Expand Down
42 changes: 42 additions & 0 deletions llp/0564-skill-evidence-updates.spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# LLP 0564: Skill evidence updates

**Type:** Spec
**Status:** Active
**Systems:** Graph, Plugins
**Date:** 2026-10-29
**Related:** LLP 0023, LLP 0428

## Skill evidence

Keep existing Skill and File identities. A shell reference to a skill file is
not an invocation. New shell read evidence uses `dispatch_file_read`; searches
use `dispatch_shell_search`. Legacy `dispatch_shell_read` may mean either and
must remain visibly unverified. Explicit tool/slash flags mean an invocation
was requested, never that it executed successfully. The marker means supplied
instructions. Presence flags combine; a session-skill relationship is not an
event count. Text output calls the relationship skill evidence and names the
flags. JSON preserves the raw relation and flags for filtering.

## Incremental evidence

Extend LLP 0428's append-only default narrowly: existing `ran` edges to Skill
receive merged evidence from their owning projector/source dataset. Partial
source scans must preserve earlier evidence, first-seen time, and exemplar
rules. Reuse the conditional maintenance generation swap; fail on unreadable
partitions or concurrent writes. Unchanged rows cause no rewrite. Other graph
identities keep their existing behavior. No version-triggered replacement.

Explicit `graph project --refresh` remains the correction path for old labels:
re-derive the complete intended source scope with the current parser and replace
matching owned rows. An incremental merge must never reinterpret a legacy flag
as a verified read or silently retract historical evidence.

## Cost

Parse shell operands once per action and retain only the current source row.
An ordinary projection containing existing skill relationships streams the edge
tables once more to merge evidence. It retains a replacement map only for
changed matching rows and reuses 10,000-row rewrite batches. A changed
relationship requires rewriting its partition; this is an offline projection
cost, not a query-time cost. Existing full-source and identity scans remain.
No dependencies, timer, or persistent cache are added.
8 changes: 4 additions & 4 deletions test/plugins/ai-gateway-graph-contract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ test('contract carries its source/projector metadata', () => {
// @ref LLP 0073#additive-no-migration: the Program/invoked rules bump the
// projector version 1 → 2 as provenance only (ids are content-addressed; no
// re-key, no migration).
test('PROJECTOR_VERSION is 3 after precise action evidence', () => {
assert.equal(PROJECTOR_VERSION, 3)
assert.equal(contract.projectorVersion, 3)
test('PROJECTOR_VERSION is 4 after skill evidence classification', () => {
assert.equal(PROJECTOR_VERSION, 4)
assert.equal(contract.projectorVersion, 4)
})

// @ref LLP 0030#decision: the Session node keys on session_id (the
Expand Down Expand Up @@ -527,7 +527,7 @@ test('ran edges wire Session -> Skill with exactly their own dispatch flag', ()

const codex = rule('edge', 'ran', SKILL_CODEX).toRow({ session_id: 'sess-1', tool_args: CODEX_READ_ARGS, message_created_at: TS })
assert.ok(codex)
assert.deepEqual(codex.props, { dispatch_shell_read: true })
assert.deepEqual(codex.props, { dispatch_file_read: true })
assert.deepEqual(codex.source_keys, { session_id: 'sess-1', tool_name: 'exec_command', skill: 'hypaware-query' })

// Same (session, skill) pair from every surface -> the SAME edge id, so the
Expand Down
Loading
Loading