@inneranimalmedia/agentsam-sdk 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +27 -15
  2. package/docs/CAPABILITIES.md +93 -0
  3. package/docs/RELEASES.md +2 -1
  4. package/docs/portable-knowledge.md +4 -4
  5. package/package.json +7 -1
  6. package/packages/identity/package.json +1 -1
  7. package/packages/identity/src/frontend/auth-portal/README.md +1 -1
  8. package/packages/identity/src/index.js +2 -0
  9. package/protocol/capabilities/capability-manifest.schema.json +36 -0
  10. package/protocol/capabilities/manifest.json +179 -0
  11. package/protocol/capabilities/repository-audit-input.schema.json +14 -0
  12. package/protocol/capabilities/repository-audit.schema.json +30 -0
  13. package/protocol/capabilities/repository-snapshot-input.schema.json +11 -0
  14. package/protocol/capabilities/repository-snapshot.schema.json +20 -0
  15. package/protocol/knowledge/chunk.schema.json +15 -73
  16. package/protocol/knowledge/document.schema.json +10 -48
  17. package/protocol/knowledge/index-config.schema.json +2 -2
  18. package/protocol/knowledge/repository.schema.json +11 -52
  19. package/protocol/knowledge/retrieval-query.schema.json +13 -63
  20. package/protocol/knowledge/source.schema.json +9 -43
  21. package/protocol/presets/catalog.json +48 -0
  22. package/python/agentsam_sdk/knowledge/models.py +19 -7
  23. package/python/agentsam_sdk/repository/__main__.py +2 -2
  24. package/python/tests/test_knowledge_models.py +6 -2
  25. package/src/agent/capability-adapter.js +50 -0
  26. package/src/agent/index.js +2 -0
  27. package/src/agent/repository-audit.js +188 -0
  28. package/src/capabilities/index.js +7 -0
  29. package/src/capabilities/manifest.js +22 -0
  30. package/src/capabilities/repository-snapshot.js +180 -0
  31. package/src/cli.js +53 -1
  32. package/src/commands/deploy.js +0 -1
  33. package/src/commands/knowledge.js +5 -6
  34. package/src/commands/product.js +119 -0
  35. package/src/index.js +9 -0
  36. package/src/knowledge/config.js +12 -6
  37. package/src/knowledge/contracts.js +1 -1
  38. package/src/knowledge/engine.js +1 -1
  39. package/src/knowledge/service/server.js +2 -2
  40. package/src/lib/git-context.js +3 -1
  41. package/src/presets/index.js +20 -0
  42. package/src/repository/index.js +4 -0
  43. package/test/agent-capabilities.test.mjs +67 -0
  44. package/test/capabilities.test.mjs +84 -0
  45. package/test/portable-context.test.mjs +11 -1
@@ -37,7 +37,8 @@ export function validateConfig(input) {
37
37
  allowed(c.embedding, ['provider', 'model', 'revision', 'dimensions', 'parameters'], 'embedding');
38
38
  allowed(c.storage, ['driver', 'connection_env'], 'storage');
39
39
  if (c.version !== 1) throw new Error('Unsupported knowledge configuration version.');
40
- for (const key of ['repository_id', 'workspace_id']) if (typeof c[key] !== 'string' || !c[key].trim()) throw new Error(`${key} is required.`);
40
+ if (typeof c.repository_id !== 'string' || !c.repository_id.trim()) throw new Error('repository_id is required.');
41
+ if (c.workspace_id != null && (typeof c.workspace_id !== 'string' || !c.workspace_id.trim())) throw new Error('workspace_id must be a non-empty string when present.');
41
42
  if (typeof c.scope?.name !== 'string' || !c.scope.name.trim() || !Array.isArray(c.scope.include) || !c.scope.include.length) throw new Error('A named scope with at least one include path is required.');
42
43
  c.scope.include = [...new Set(c.scope.include.map(relativeSelection))].sort();
43
44
  c.scope.exclude = [...new Set((c.scope.exclude || []).map(relativeSelection))].sort();
@@ -49,10 +50,9 @@ export function validateConfig(input) {
49
50
  if (c.storage.driver === 'postgres' && !/^[A-Z_][A-Z0-9_]*$/.test(c.storage.connection_env || '')) throw new Error('Postgres requires a connection_env name, never a connection string in config.');
50
51
  return c;
51
52
  }
52
- export function defaultConfig({ include = ['.'], exclude = [], scope = 'default', workspace = 'local', target = 'local', dimensions = 768 } = {}) {
53
+ export function defaultConfig({ include = ['.'], exclude = [], scope = 'default', target = 'local', dimensions = 768 } = {}) {
53
54
  if (!['local', 'production'].includes(target)) throw new Error('target must be local or production.');
54
- if (target === 'production' && workspace === 'local') throw new Error('Production requires an explicit workspace identifier.');
55
- return validateConfig({ version: 1, repository_id: randomUUID(), workspace_id: workspace,
55
+ return validateConfig({ version: 1, repository_id: randomUUID(),
56
56
  scope: { name: scope, include, exclude }, chunking: { max_chars: 4000 },
57
57
  embedding: { provider: 'gemini', model: 'gemini-embedding-2', revision: '1', dimensions, parameters: { task: 'code retrieval' } },
58
58
  storage: target === 'local' ? { driver: 'sqlite' } : { driver: 'postgres', connection_env: 'AGENTSAM_DATABASE_URL' } });
@@ -65,5 +65,11 @@ export function initRepository(root, options = {}) {
65
65
  fs.writeFileSync(target, JSON.stringify(config, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
66
66
  return config;
67
67
  }
68
- export const scopeKey = config => fingerprint([config.workspace_id, config.repository_id, config.scope.name]);
69
- export const cacheNamespace = config => fingerprint([config.workspace_id, config.repository_id]);
68
+ // New portable configs are repository-scoped. Legacy configs that still carry
69
+ // workspace_id retain their original namespace so existing local generations stay readable.
70
+ export const scopeKey = config => config.workspace_id
71
+ ? fingerprint([config.workspace_id, config.repository_id, config.scope.name])
72
+ : fingerprint([config.repository_id, config.scope.name]);
73
+ export const cacheNamespace = config => config.workspace_id
74
+ ? fingerprint([config.workspace_id, config.repository_id])
75
+ : fingerprint([config.repository_id]);
@@ -8,7 +8,7 @@ export const KNOWLEDGE_OPERATIONS = Object.freeze({
8
8
  export function assertRetrievalQuery(value) {
9
9
  if (!value || typeof value !== "object") throw new TypeError("RetrievalQuery must be an object");
10
10
  if (!String(value.text || "").trim()) throw new TypeError("RetrievalQuery.text is required");
11
- if (!String(value.workspace_id || "").trim()) throw new TypeError("RetrievalQuery.workspace_id is required");
11
+ if (value.workspace_id != null && !String(value.workspace_id).trim()) throw new TypeError("RetrievalQuery.workspace_id must be non-empty when present");
12
12
  const topK = value.top_k ?? 12;
13
13
  if (!Number.isInteger(topK) || topK < 1 || topK > 100) throw new RangeError("top_k must be 1..100");
14
14
  if (!Number.isInteger(value.token_budget ?? 8000) || (value.token_budget ?? 8000) < 256) throw new RangeError("token_budget must be at least 256");
@@ -113,6 +113,6 @@ export async function retrieve({ store, config: input, text, semantic = false, e
113
113
  metadata: { generation_id: generation.id, symbol: hit.symbol, content_hash: hit.content_hash, freshness: 'working tree not checked' } }); estimatedTokens += tokens;
114
114
  if (hits.length === topK) break;
115
115
  }
116
- return createContextPack({ queryId: randomUUID(), query: { text, workspace_id: config.workspace_id, top_k: topK, token_budget: tokenBudget }, hits,
116
+ return createContextPack({ queryId: randomUUID(), query: { text, top_k: topK, token_budget: tokenBudget }, hits,
117
117
  diagnostics: { generation_id: generation.id, source_hash: generation.source_hash, scope: generation.config.scope, mode: semantic ? 'semantic-exact' : 'lexical', freshness: 'snapshot; working tree not checked' } });
118
118
  }
@@ -100,7 +100,7 @@ export async function startKnowledgeService({ stateDir, repositories, token, por
100
100
  });
101
101
  // Re-resolve registry on replay. Removed repositories do not get resumed.
102
102
  const payload = JSON.parse(row.payload), registered = repositories[payload.request.repository];
103
- if (!registered || registered.config.repository_id !== payload.config.repository_id || registered.config.workspace_id !== payload.config.workspace_id || fingerprint(registered.config.scope) !== payload.registered_scope || (!allowEmbeddings && ((payload.request.embed && payload.request.operation !== 'plan') || payload.request.semantic))) {
103
+ if (!registered || registered.config.repository_id !== payload.config.repository_id || fingerprint(registered.config.scope) !== payload.registered_scope || (!allowEmbeddings && ((payload.request.embed && payload.request.operation !== 'plan') || payload.request.semantic))) {
104
104
  response = { ok: false, error: 'Repository registration changed; resubmit this job.' }; child.kill(); return;
105
105
  }
106
106
  child.send({ ...payload, root: registered.root, filename: path.join(stateDir, 'knowledge.sqlite'), maxFiles });
@@ -124,7 +124,7 @@ export async function startKnowledgeService({ stateDir, repositories, token, por
124
124
  payload.registered_scope = fingerprint(repositories[payload.request.repository].config.scope);
125
125
  const key = req.headers['idempotency-key'];
126
126
  if (key !== undefined && (typeof key !== 'string' || !/^[\w:.-]{1,128}$/.test(key))) throw fail(400, 'Invalid Idempotency-Key.');
127
- const digest = fingerprint(payload), idem = key ? fingerprint([payload.config.workspace_id, payload.config.repository_id, key]) : null;
127
+ const digest = fingerprint(payload), idem = key ? fingerprint([payload.config.repository_id, payload.registered_scope, key]) : null;
128
128
  const previous = idem && db.prepare('SELECT * FROM jobs WHERE idem=?').get(idem);
129
129
  if (previous) {
130
130
  if (previous.digest !== digest) throw fail(409, 'Idempotency-Key already used for a different job.');
@@ -66,7 +66,9 @@ export function resolveGitContext(options = {}) {
66
66
  ? preferredRemote
67
67
  : availableRemotes[0] || preferredRemote;
68
68
  const remoteUrl = runGit(root, ['remote', 'get-url', remote], { required: false });
69
- const revisionSha = runGit(root, ['rev-parse', 'HEAD']);
69
+ // A newly initialized repository can have an unborn HEAD. Repository identity
70
+ // and local inspection must still work before the first commit.
71
+ const revisionSha = runGit(root, ['rev-parse', 'HEAD'], { required: false }) || null;
70
72
  const branch = runGit(root, ['branch', '--show-current'], { required: false }) || null;
71
73
  const status = runGit(root, ['status', '--porcelain=v1'], { required: false });
72
74
 
@@ -0,0 +1,20 @@
1
+ import catalog from '../../protocol/presets/catalog.json' with { type: 'json' };
2
+
3
+ export const PRESET_CATALOG_VERSION = catalog.schema_version;
4
+
5
+ export function getPresetCatalog() { return structuredClone(catalog); }
6
+ export function listPresets() { return Object.values(catalog.presets).map((row) => structuredClone(row)); }
7
+ export function getPreset(id) {
8
+ const row = catalog.presets[String(id || '').trim().toLowerCase()];
9
+ return row ? structuredClone(row) : null;
10
+ }
11
+ export function resolvePreset(id = 'fullstack') {
12
+ const preset = getPreset(id);
13
+ if (!preset) throw new Error(`unknown_preset:${id}; expected ${Object.keys(catalog.presets).join(',')}`);
14
+ return preset;
15
+ }
16
+ export function listAddons() { return Object.values(catalog.addons).map((row) => structuredClone(row)); }
17
+ export function getAddon(id) {
18
+ const row = catalog.addons[String(id || '').trim().toLowerCase()];
19
+ return row ? structuredClone(row) : null;
20
+ }
@@ -0,0 +1,4 @@
1
+ export { repositorySnapshot } from '../capabilities/repository-snapshot.js';
2
+ export { buildMerkleTree, diffTrees, readSnapshot, saveSnapshot, validateSnapshot } from '../lib/merkle/index.js';
3
+ export { resolveGitContext, tryResolveGitContext, normalizeGitRemote } from '../lib/git-context.js';
4
+ export { buildRetrievalPlan, createContextPack } from '../knowledge/index.js';
@@ -0,0 +1,67 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createCapabilityAdapter } from '../src/agent/index.js';
4
+ import { buildRepositoryAuditPacket, runRepositoryAudit } from '../src/agent/repository-audit.js';
5
+
6
+ const snapshot = {
7
+ schema_version: 1,
8
+ capability: 'repository.snapshot',
9
+ snapshot_id: 'rsnap_0123456789abcdef01234567',
10
+ content_hash: `sha256:${'a'.repeat(64)}`,
11
+ repository: { repository_id: 'github:owner/repo', provider: 'github', full_name: 'owner/repo', branch: 'main', revision_sha: 'b'.repeat(40), dirty: false },
12
+ tree: { merkle_root: `sha256:${'c'.repeat(64)}`, stats: { files: 10 } },
13
+ intelligence: {
14
+ summary: { file_count: 10, total_lines: 500 },
15
+ languages: [{ language: 'JavaScript', files: 7 }],
16
+ manifests: [{ path: 'package.json', kind: 'node' }],
17
+ top_level: [{ path: 'src', files: 7 }],
18
+ pressure_points: [{ path: 'src', pressure_score: 70 }],
19
+ },
20
+ packages: [{ path: 'package.json', name: 'demo', version: '1.0.0' }],
21
+ knowledge: { configured: false },
22
+ deploy: null,
23
+ };
24
+
25
+ test('repository audit packet is bounded read-only evidence', () => {
26
+ const packet = buildRepositoryAuditPacket({ snapshot, focus: ['packages'], evidenceBudget: 1000 });
27
+ assert.equal(packet.primitive, 'repository.audit');
28
+ assert.equal(packet.rules.read_only, true);
29
+ assert.equal(packet.rules.may_edit, false);
30
+ assert.equal(packet.snapshot_id, snapshot.snapshot_id);
31
+ assert.ok(JSON.stringify(packet.evidence).length <= 4000);
32
+ });
33
+
34
+ test('repository audit uses injected reasoning and validates read-only output', async () => {
35
+ const audit = await runRepositoryAudit({
36
+ snapshot,
37
+ reasoner: async packet => ({
38
+ summary: `Audited ${packet.evidence.repository.full_name}`,
39
+ packages: packet.evidence.packages,
40
+ notable_combinations: ['snapshot + audit'],
41
+ findings: [{ severity: 'low', finding: 'example' }],
42
+ recommended_routes: ['inspect capability registry'],
43
+ evidence_refs: [packet.evidence_index.packages],
44
+ }),
45
+ });
46
+ assert.equal(audit.primitive, 'repository.audit');
47
+ assert.equal(audit.summary, 'Audited owner/repo');
48
+ assert.equal(audit.packages.length, 1);
49
+ assert.deepEqual(audit.commands, []);
50
+
51
+ await assert.rejects(() => runRepositoryAudit({ snapshot, reasoner: async () => ({ summary: 'bad', jobs: [] }) }), /mutation field: jobs/);
52
+ });
53
+
54
+ test('agent capability adapter exposes only executable tools by default', async () => {
55
+ const adapter = createCapabilityAdapter({
56
+ reasoner: async packet => ({ summary: 'ok', evidence_refs: [packet.evidence_index.summary] }),
57
+ handlers: { 'knowledge.search': async input => ({ query: input.text, hits: [] }) },
58
+ });
59
+ const names = adapter.toolDescriptors().map(row => row.name);
60
+ assert.ok(names.includes('repository.snapshot'));
61
+ assert.ok(names.includes('repository.audit'));
62
+ assert.ok(names.includes('knowledge.search'));
63
+ assert.equal(names.includes('knowledge.index'), false);
64
+ const result = await adapter.invoke('knowledge.search', { text: 'hello' });
65
+ assert.equal(result.capability_id, 'knowledge.search');
66
+ assert.deepEqual(result.result.hits, []);
67
+ });
@@ -0,0 +1,84 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { getCapability, getCapabilityManifest, repositorySnapshot } from '../src/capabilities/index.js';
8
+ import { getPreset, resolvePreset } from '../src/presets/index.js';
9
+
10
+ const CLI = path.resolve('src/cli.js');
11
+
12
+ function git(cwd, args) { return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); }
13
+
14
+ function fixture() {
15
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-capability-'));
16
+ fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'snapshot-fixture', version: '1.0.0', type: 'module' }, null, 2));
17
+ fs.mkdirSync(path.join(root, 'src'));
18
+ fs.writeFileSync(path.join(root, 'src', 'index.js'), 'export const value = 1;\n');
19
+ git(root, ['init', '-q']);
20
+ git(root, ['config', 'user.email', 'test@example.com']);
21
+ git(root, ['config', 'user.name', 'AgentSam Test']);
22
+ git(root, ['add', '.']);
23
+ git(root, ['commit', '-qm', 'fixture']);
24
+ return root;
25
+ }
26
+
27
+ test('capability manifest exposes deterministic primitives without requiring a model', () => {
28
+ const manifest = getCapabilityManifest();
29
+ assert.equal(manifest.schema_version, 1);
30
+ assert.equal(getCapability('repository.snapshot').model_required, false);
31
+ assert.equal(getCapability('repository.snapshot').side_effects, 'none');
32
+ assert.equal(getCapability('knowledge.index').deterministic, true);
33
+ });
34
+
35
+ test('presets resolve to explicit feature/capability selections', () => {
36
+ assert.equal(getPreset('cms').lane, 'cms');
37
+ const preset = resolvePreset('prototype');
38
+ assert.deepEqual(preset.features, ['agent']);
39
+ assert.throws(() => resolvePreset('nope'), /unknown_preset/);
40
+ });
41
+
42
+ test('repository.snapshot composes deterministic evidence and content addressing', async t => {
43
+ const root = fixture();
44
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
45
+ const one = await repositorySnapshot({ cwd: root, churnDays: 30 });
46
+ const two = await repositorySnapshot({ cwd: root, churnDays: 30 });
47
+ assert.equal(one.capability, 'repository.snapshot');
48
+ assert.match(one.snapshot_id, /^rsnap_[a-f0-9]{24}$/);
49
+ assert.match(one.content_hash, /^sha256:[a-f0-9]{64}$/);
50
+ assert.equal(one.content_hash, two.content_hash);
51
+ assert.equal(one.snapshot_id, two.snapshot_id);
52
+ assert.equal(one.repository.revision_sha, git(root, ['rev-parse', 'HEAD']));
53
+ assert.ok(one.tree.merkle_root);
54
+ assert.ok(one.intelligence.summary.file_count >= 2);
55
+ assert.equal(one.packages[0].name, 'snapshot-fixture');
56
+ assert.equal(one.knowledge.configured, false);
57
+ });
58
+
59
+ test('product UX creates a preset project, adds a feature, and inspects before first commit', t => {
60
+ const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-product-'));
61
+ t.after(() => fs.rmSync(parent, { recursive: true, force: true }));
62
+ execFileSync(process.execPath, [CLI, 'create', 'demo', '--preset', 'cms', '--target', 'local'], {
63
+ cwd: parent,
64
+ stdio: 'pipe',
65
+ });
66
+ const project = path.join(parent, 'demo');
67
+ const config = JSON.parse(fs.readFileSync(path.join(project, '.agentsam', 'config.json'), 'utf8'));
68
+ assert.equal(config.preset, 'cms');
69
+ assert.equal(config.lane, 'cms');
70
+ assert.deepEqual(config.features, ['cms', 'knowledge']);
71
+ assert.ok(config.capabilities.includes('repository.snapshot'));
72
+
73
+ execFileSync(process.execPath, [CLI, 'add', 'knowledge', '--cwd', project, '--json'], { stdio: 'pipe' });
74
+ const features = JSON.parse(fs.readFileSync(path.join(project, '.agentsam', 'features.json'), 'utf8'));
75
+ assert.equal(features.features.knowledge.selected, true);
76
+
77
+ const snapshot = JSON.parse(execFileSync(process.execPath, [CLI, 'inspect', '--cwd', project, '--json'], {
78
+ encoding: 'utf8',
79
+ stdio: ['ignore', 'pipe', 'pipe'],
80
+ }));
81
+ assert.equal(snapshot.capability, 'repository.snapshot');
82
+ assert.equal(snapshot.repository.revision_sha, null);
83
+ assert.ok(snapshot.tree.stats.files > 0);
84
+ });
@@ -1,7 +1,7 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { execFileSync } from 'node:child_process';
4
- import { mkdtempSync, writeFileSync } from 'node:fs';
4
+ import { mkdtempSync, realpathSync, writeFileSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
 
@@ -39,6 +39,16 @@ test('resolveGitContext derives repository identity from Git without user/worksp
39
39
  assert.equal('userId' in ctx, false);
40
40
  });
41
41
 
42
+ test('resolveGitContext supports an initialized repository before its first commit', () => {
43
+ const root = mkdtempSync(join(tmpdir(), 'agentsam-sdk-unborn-'));
44
+ execFileSync('git', ['init'], { cwd: root, stdio: 'ignore' });
45
+ writeFileSync(join(root, 'README.md'), '# unborn\n');
46
+ const ctx = resolveGitContext({ cwd: root });
47
+ assert.equal(ctx.root, realpathSync(root));
48
+ assert.equal(ctx.revisionSha, null);
49
+ assert.equal(ctx.dirty, true);
50
+ });
51
+
42
52
  test('bridge headers authenticate only the machine principal', () => {
43
53
  const headers = buildBridgeHeaders({
44
54
  env: {