@astrale-os/cli 1.0.0-beta.26 → 1.0.0-beta.28

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 (73) hide show
  1. package/README.md +31 -5
  2. package/dist/astrale.js +1201 -358
  3. package/package.json +3 -2
  4. package/src/commands/__tests__/domain-install-operation.test.ts +23 -0
  5. package/src/commands/__tests__/install-identity-override.test.ts +3 -3
  6. package/src/commands/domain/install.ts +19 -11
  7. package/src/commands/get.ts +1 -1
  8. package/src/commands/identity/register.ts +3 -4
  9. package/src/commands/logs.ts +2 -5
  10. package/src/commands/session/analyze.ts +26 -4
  11. package/src/commands/update.ts +61 -40
  12. package/src/lib/__tests__/skills.test.ts +809 -0
  13. package/src/lib/skills/lock.ts +117 -0
  14. package/src/lib/skills/sync.ts +814 -0
  15. package/src/lib/skills.ts +1 -52
  16. package/src/program/__tests__/program.test.ts +1 -1
  17. package/src/setup/steps/skills.ts +40 -38
  18. package/src/telemetry/__tests__/retention.test.ts +180 -0
  19. package/src/telemetry/__tests__/settings.test.ts +102 -0
  20. package/src/telemetry/__tests__/store-scan.test.ts +128 -0
  21. package/src/telemetry/__tests__/trigger.test.ts +33 -4
  22. package/src/telemetry/recorder.ts +6 -3
  23. package/src/telemetry/retention.ts +129 -0
  24. package/src/telemetry/session.ts +18 -12
  25. package/src/telemetry/settings.ts +64 -11
  26. package/src/telemetry/store.ts +87 -9
  27. package/src/telemetry/trigger.ts +16 -28
  28. package/studio/client/dist/assets/index-BFVFs_8x.js +81 -0
  29. package/studio/client/dist/assets/index-DuB9iUdu.css +2 -0
  30. package/studio/client/dist/assets/schema-studio--a0kX3QU.css +1 -0
  31. package/studio/client/dist/assets/schema-studio-DH6oEWME.js +8 -0
  32. package/studio/client/dist/index.html +3 -3
  33. package/studio/server/agent/prompts/anchors.test.ts +17 -4
  34. package/studio/server/agent/prompts/anchors.ts +3 -2
  35. package/studio/server/agent/prompts/system.test.ts +2 -3
  36. package/studio/server/agent/prompts/system.ts +3 -3
  37. package/studio/server/agent/run/preparation.ts +1 -1
  38. package/studio/server/api/context.ts +1 -1
  39. package/studio/server/api/deployment.ts +1 -1
  40. package/studio/server/api/views.ts +2 -2
  41. package/studio/server/api/workspace.ts +1 -1
  42. package/studio/server/cache.test.ts +3 -8
  43. package/studio/server/cache.ts +4 -45
  44. package/studio/server/cli-consumers.test.ts +31 -2
  45. package/studio/server/handoff/copy.ts +1 -1
  46. package/studio/server/introspect/canonical-schema.test.ts +154 -128
  47. package/studio/server/introspect/canonical-schema.ts +183 -302
  48. package/studio/server/introspect/core.ts +14 -21
  49. package/studio/server/introspect/extractor.ts +11 -15
  50. package/studio/server/introspect/overlay-tsmorph.test.ts +1 -5
  51. package/studio/server/introspect/overlay-tsmorph.ts +0 -1
  52. package/studio/server/introspect/overlay.ts +3 -24
  53. package/studio/server/introspect/revision.test.ts +24 -28
  54. package/studio/server/introspect/revision.ts +10 -21
  55. package/studio/server/introspect/runtime.test.ts +14 -14
  56. package/studio/server/introspect/runtime.ts +1 -46
  57. package/studio/server/lifecycle.ts +4 -1
  58. package/studio/server/state/documents.test.ts +69 -0
  59. package/studio/server/state/documents.ts +57 -10
  60. package/studio/server/workspace/updates.ts +23 -1
  61. package/studio/shared/contracts/runtime.ts +4 -0
  62. package/studio/shared/contracts/schema.ts +10 -26
  63. package/studio/shared/contracts/surface.test.ts +2 -2
  64. package/studio/shared/contracts/workspace.ts +3 -0
  65. package/studio/shared/schema/identity.ts +0 -1
  66. package/tsconfig.json +1 -1
  67. package/studio/client/dist/assets/index-C0FAnwNw.css +0 -2
  68. package/studio/client/dist/assets/index-OeJK1TjG.js +0 -81
  69. package/studio/client/dist/assets/schema-studio-BilUNb6Z.css +0 -1
  70. package/studio/client/dist/assets/schema-studio-DRdfu_cQ.js +0 -8
  71. package/studio/server/introspect/core-extractor.ts +0 -30
  72. package/studio/server/introspect/overlay.test.ts +0 -44
  73. package/studio/server/introspect/source-overlay/annotations.ts +0 -14
@@ -1,9 +1,7 @@
1
1
  /** Bun subprocess that imports only the authored Schema entry and its installed SDK. */
2
- import {
3
- admitCanonicalSchemaFromSdk,
4
- findCanonicalDomainSchemaExport,
5
- projectCanonicalSchema,
6
- } from './canonical-schema'
2
+ import type { SchemaSdk } from './canonical-schema'
3
+
4
+ import { extractCanonicalSchemaFromSdk, findCanonicalDomainSchemaExport } from './canonical-schema'
7
5
 
8
6
  const schemaPath = process.argv[2]
9
7
  const projectRoot = process.argv[3] ?? process.cwd()
@@ -15,19 +13,17 @@ async function main(): Promise<void> {
15
13
  if (candidate === null) {
16
14
  throw new Error('Schema entry exports no canonical V1 DomainSchema.')
17
15
  }
18
- const sdk = (await import(Bun.resolveSync('@astrale-os/sdk/schema', projectRoot))) as Record<
19
- string,
20
- unknown
21
- >
22
- const admission = admitCanonicalSchemaFromSdk(sdk, candidate)
23
- const projected = projectCanonicalSchema(admission.root, admission.closure)
16
+ const sdk = (await import(
17
+ Bun.resolveSync('@astrale-os/sdk/schema', projectRoot)
18
+ )) as unknown as SchemaSdk
19
+ const extraction = extractCanonicalSchemaFromSdk(sdk, candidate)
24
20
  process.stdout.write(
25
21
  JSON.stringify({
26
22
  ok: true,
27
- ir: projected.ir,
28
- root: admission.root,
29
- schemaMode: admission.status === 'admitted' ? 'canonical-admitted' : 'canonical-preview',
30
- revision: admission.revision,
23
+ ir: extraction.ir,
24
+ root: extraction.root,
25
+ schemaMode: extraction.status === 'admitted' ? 'canonical-admitted' : 'canonical-preview',
26
+ revision: extraction.revision,
31
27
  }),
32
28
  )
33
29
  }
@@ -5,7 +5,7 @@ import { join } from 'node:path'
5
5
 
6
6
  import type { SchemaIR } from '../../shared/types'
7
7
 
8
- import { buildHandlerLinks, buildSchemaAnnotations, buildSourceSpans } from './overlay-tsmorph'
8
+ import { buildHandlerLinks, buildSourceSpans } from './overlay-tsmorph'
9
9
 
10
10
  const roots: string[] = []
11
11
  afterEach(() => {
@@ -139,8 +139,4 @@ describe('source overlay', () => {
139
139
  expect(spans['edge.assigned_to.property.note']?.file).toBe('schema/members.ts')
140
140
  expect(spans['function.createIssue']?.file).toBe('schema/members.ts')
141
141
  })
142
-
143
- test('does not invent warnings for admitted enum values', () => {
144
- expect(buildSchemaAnnotations({ ir })).toEqual([])
145
- })
146
142
  })
@@ -4,6 +4,5 @@
4
4
  * Keep this path stable for the overlay composer and focused tests. The
5
5
  * implementation lives under source-overlay, split by responsibility.
6
6
  */
7
- export { buildSchemaAnnotations } from './source-overlay/annotations'
8
7
  export { buildHandlerLinks } from './source-overlay/handlers'
9
8
  export { buildSourceSpans } from './source-overlay/spans'
@@ -1,12 +1,9 @@
1
1
  /**
2
- * overlay.ts the Studio overlay over the DSL IR. Computes what the IR cannot
3
- * carry. The import split and dependency list are computed here; handlerLinks,
4
- * static parse). handlerLinks, sourceSpans (+JSDoc) and annotations are filled
5
- * by a ts-morph pass over current Schema, Runtime, Action, and Workflow files.
2
+ * Source-only metadata that the admitted DSL Schema cannot carry.
6
3
  */
7
- import type { CrossDomainImport, SchemaIR, SchemaOverlay } from '../../shared/types'
4
+ import type { SchemaIR, SchemaOverlay } from '../../shared/types'
8
5
 
9
- import { buildHandlerLinks, buildSchemaAnnotations, buildSourceSpans } from './overlay-tsmorph'
6
+ import { buildHandlerLinks, buildSourceSpans } from './overlay-tsmorph'
10
7
 
11
8
  export interface OverlayArgs {
12
9
  ir: SchemaIR | null
@@ -15,26 +12,8 @@ export interface OverlayArgs {
15
12
  }
16
13
 
17
14
  export function buildOverlay({ ir, domainRoot, schemaDir }: OverlayArgs): SchemaOverlay {
18
- const origin = ir?.domain ?? ''
19
- const mixins: CrossDomainImport[] = []
20
- const crossDomainImports: CrossDomainImport[] = []
21
- for (const descriptor of Object.values(ir?.importsByKey ?? {})) {
22
- const entry: CrossDomainImport = {
23
- name: descriptor.ref.name,
24
- origin: descriptor.origin,
25
- ref: descriptor.ref,
26
- }
27
- if (descriptor.origin === 'kernel.astrale.ai') mixins.push(entry)
28
- else crossDomainImports.push(entry)
29
- }
30
-
31
15
  return {
32
- origin,
33
- requires: (ir?.dependencies ?? []).map(({ origin }) => origin),
34
- crossDomainImports,
35
- mixins,
36
16
  handlerLinks: buildHandlerLinks({ ir, domainRoot }),
37
17
  sourceSpans: buildSourceSpans({ ir, schemaDir }),
38
- annotations: buildSchemaAnnotations({ ir }),
39
18
  }
40
19
  }
@@ -1,53 +1,49 @@
1
+ import * as sdk from '@astrale-os/sdk/schema'
2
+ import { defineSchema } from '@astrale-os/sdk/schema'
1
3
  import { describe, expect, test } from 'bun:test'
2
4
 
3
5
  import { admittedBundleRevisionFromSdk } from './revision'
4
6
 
5
- const REVISION = `sha256:${'a'.repeat(64)}` as const
7
+ const schema = defineSchema('notes.example.dev', {})
8
+ const wire = JSON.parse(JSON.stringify(sdk.bundle.create(schema)))
6
9
 
7
10
  describe('installed Bundle revision', () => {
8
- test('uses bundle admission before asking the installed schema owner for its revision', () => {
11
+ test('uses Bundle admission and the resolved Domain identity', () => {
9
12
  const calls: string[] = []
10
- const root = { format: 'astrale.dsl', version: 'v1', origin: 'notes.example.dev' }
11
- const input = { wire: true }
12
- const sdk = {
13
+ const wrapped = {
14
+ ...sdk,
13
15
  bundle: {
16
+ ...sdk.bundle,
14
17
  accept(value: unknown) {
15
18
  calls.push('bundle.accept')
16
- expect(value).toBe(input)
17
- return { root, closure: [] }
19
+ return sdk.bundle.accept(value)
18
20
  },
19
21
  },
20
22
  schema: {
21
- revision(value: unknown) {
22
- calls.push('schema.revision')
23
- expect(value).toBe(root)
24
- return REVISION
23
+ ...sdk.schema,
24
+ resolve(value: typeof schema) {
25
+ calls.push('schema.resolve')
26
+ return sdk.schema.resolve(value)
25
27
  },
26
28
  },
27
- }
29
+ } as unknown as typeof sdk
28
30
 
29
- expect(admittedBundleRevisionFromSdk(sdk, input)).toBe(REVISION)
30
- expect(calls).toEqual(['bundle.accept', 'schema.revision'])
31
+ expect(admittedBundleRevisionFromSdk(wrapped, wire)).toBe(sdk.schema.revision(schema))
32
+ expect(calls).toEqual(['bundle.accept', 'schema.resolve'])
31
33
  })
32
34
 
33
- test('fails closed when bundle admission or the returned revision is invalid', () => {
34
- expect(
35
- admittedBundleRevisionFromSdk(
36
- { bundle: { accept: () => ({ root: {} }) }, schema: { revision: () => 'sha-short' } },
37
- {},
38
- ),
39
- ).toBeNull()
35
+ test('fails closed when Bundle admission or the resolved identity is invalid', () => {
36
+ expect(admittedBundleRevisionFromSdk({} as typeof sdk, {})).toBeNull()
40
37
  expect(
41
38
  admittedBundleRevisionFromSdk(
42
39
  {
43
- bundle: {
44
- accept: () => {
45
- throw new Error('invalid bundle')
46
- },
40
+ ...sdk,
41
+ schema: {
42
+ ...sdk.schema,
43
+ resolve: () => ({ source: schema, revision: 'sha-short' }),
47
44
  },
48
- schema: { revision: () => REVISION },
49
- },
50
- {},
45
+ } as unknown as typeof sdk,
46
+ wire,
51
47
  ),
52
48
  ).toBeNull()
53
49
  })
@@ -1,28 +1,23 @@
1
1
  import type { SchemaRevision } from '../../shared/types'
2
+ import type { SchemaAdmissionSdk } from './canonical-schema'
2
3
 
3
4
  import { isSchemaRevision } from '../../shared/types'
4
5
 
5
- type AnyRecord = Record<string, unknown>
6
-
7
6
  /**
8
7
  * Cross the installed-bundle JSON boundary through the Domain's installed SDK, then
9
8
  * ask the DSL owner for the root revision. Invalid/unavailable bundles return
10
9
  * null so Studio reports unknown drift instead of inventing an identity.
11
10
  */
12
11
  export function admittedBundleRevisionFromSdk(
13
- sdkModule: Record<string, unknown>,
12
+ sdk: SchemaAdmissionSdk,
14
13
  input: unknown,
15
14
  ): SchemaRevision | null {
16
- const bundleApi = asRecord(sdkModule.bundle)
17
- const schemaApi = asRecord(sdkModule.schema)
18
- if (typeof bundleApi?.accept !== 'function' || typeof schemaApi?.revision !== 'function') {
19
- return null
20
- }
21
15
  try {
22
- const accepted = asRecord(Reflect.apply(bundleApi.accept, bundleApi, [input]))
23
- if (!accepted || !Object.prototype.hasOwnProperty.call(accepted, 'root')) return null
24
- const revision = Reflect.apply(schemaApi.revision, schemaApi, [accepted.root])
25
- return isSchemaRevision(revision) ? revision : null
16
+ const accepted = sdk.bundle.accept(input)
17
+ const domain = sdk.schema.resolve(accepted.root)
18
+ return domain.source === accepted.root && isSchemaRevision(domain.revision)
19
+ ? domain.revision
20
+ : null
26
21
  } catch {
27
22
  return null
28
23
  }
@@ -33,17 +28,11 @@ export async function admittedBundleRevision(
33
28
  input: unknown,
34
29
  ): Promise<SchemaRevision | null> {
35
30
  try {
36
- const sdkModule: Record<string, unknown> = await import(
31
+ const sdk = (await import(
37
32
  Bun.resolveSync('@astrale-os/sdk/schema', domainRoot)
38
- )
39
- return admittedBundleRevisionFromSdk(sdkModule, input)
33
+ )) as unknown as SchemaAdmissionSdk
34
+ return admittedBundleRevisionFromSdk(sdk, input)
40
35
  } catch {
41
36
  return null
42
37
  }
43
38
  }
44
-
45
- function asRecord(value: unknown): AnyRecord | null {
46
- return value !== null && typeof value === 'object' && !Array.isArray(value)
47
- ? (value as AnyRecord)
48
- : null
49
- }
@@ -6,9 +6,10 @@ import { join } from 'node:path'
6
6
  import type { DomainHandle } from '../domain'
7
7
 
8
8
  import { buildBundle } from './bundle'
9
+ import { buildCore } from './core'
9
10
  import { renderFingerprintOf } from './hash'
10
11
  import { admittedBundleRevision } from './revision'
11
- import { coreExtract, runtimeExtract } from './runtime'
12
+ import { runtimeExtract } from './runtime'
12
13
 
13
14
  const roots: string[] = []
14
15
  afterEach(() => {
@@ -128,19 +129,18 @@ describe('SDK V1 schema extractor', () => {
128
129
 
129
130
  test('extracts Core through the pure Schema entry without importing Application', async () => {
130
131
  const handle = currentFixture()
131
- expect(await coreExtract(handle.schemaIndex, handle.root)).toEqual({
132
- ok: true,
133
- core: {
134
- domain: 'documents.runtime.test',
135
- nodes: [
136
- {
137
- path: '/:documents.runtime.test:core.welcome',
138
- className: 'Document',
139
- data: {},
140
- },
141
- ],
142
- edges: [],
143
- },
132
+ const bundle = await buildBundle(handle)
133
+ expect(buildCore(handle, bundle)).toMatchObject({
134
+ domain: 'documents.runtime.test',
135
+ nodes: [
136
+ {
137
+ path: '/:documents.runtime.test:core.welcome',
138
+ className: 'Document',
139
+ data: {},
140
+ },
141
+ ],
142
+ edges: [],
143
+ error: null,
144
144
  })
145
145
  })
146
146
  })
@@ -5,12 +5,11 @@
5
5
  * render IR plus the raw canonical root (when present), or an error render-state
6
6
  * — never throws.
7
7
  */
8
- import type { SchemaIR, SchemaRevision, StudioCore, StudioSchemaBundle } from '../../shared/types'
8
+ import type { SchemaIR, SchemaRevision, StudioSchemaBundle } from '../../shared/types'
9
9
 
10
10
  import { isSchemaRevision } from '../../shared/types'
11
11
 
12
12
  const EXTRACTOR = new URL('./extractor.ts', import.meta.url).pathname
13
- const CORE_EXTRACTOR = new URL('./core-extractor.ts', import.meta.url).pathname
14
13
 
15
14
  export interface RuntimeExtractResult {
16
15
  ok: boolean
@@ -94,47 +93,3 @@ export async function runtimeExtract(
94
93
  }
95
94
  }
96
95
  }
97
-
98
- export interface CoreExtractResult {
99
- ok: boolean
100
- /** the resolved core graph, or null when the domain defines no core */
101
- core: Pick<StudioCore, 'domain' | 'nodes' | 'edges'> | null
102
- error?: { message: string }
103
- }
104
-
105
- /**
106
- * Spawn the Core extractor over the pure Schema entry. Returns the projected
107
- * graph or an error and never imports Application or Runtime modules.
108
- */
109
- export async function coreExtract(
110
- schemaIndexPath: string,
111
- domainDir: string,
112
- timeoutMs = 20000,
113
- ): Promise<CoreExtractResult> {
114
- try {
115
- const proc = Bun.spawn(['bun', 'run', CORE_EXTRACTOR, schemaIndexPath, domainDir], {
116
- cwd: domainDir,
117
- stdout: 'pipe',
118
- stderr: 'pipe',
119
- })
120
- const timer = setTimeout(() => proc.kill(9), timeoutMs)
121
- const out = await new Response(proc.stdout).text()
122
- await proc.exited
123
- clearTimeout(timer)
124
-
125
- if (!out.trim()) {
126
- const err = await new Response(proc.stderr).text()
127
- return {
128
- ok: false,
129
- core: null,
130
- error: { message: err.trim() || 'core extractor produced no output' },
131
- }
132
- }
133
- const parsed = JSON.parse(out)
134
- if (!parsed.ok)
135
- return { ok: false, core: null, error: parsed.error ?? { message: 'core extraction failed' } }
136
- return { ok: true, core: (parsed.core ?? null) as CoreExtractResult['core'] }
137
- } catch (e: any) {
138
- return { ok: false, core: null, error: { message: String(e?.message ?? e) } }
139
- }
140
- }
@@ -9,6 +9,7 @@ import type { DomainHandle } from './domain'
9
9
  */
10
10
  import { getBundle } from './cache'
11
11
  import { captureBaseline, hashAnatomyFiles, loadBaseline } from './state/baseline'
12
+ import { migrateDocuments } from './state/documents'
12
13
  import { initDotDir } from './state/store'
13
14
  import { watchDomain } from './watch'
14
15
 
@@ -22,6 +23,8 @@ export interface BootedDomain {
22
23
  /** Initialize + start watching one domain. Returns its origin + a stop handle. */
23
24
  export async function bootDomain(handle: DomainHandle): Promise<BootedDomain> {
24
25
  initDotDir(handle.root)
26
+ // one-shot: uuid-named documents become readable file names under context/docs
27
+ migrateDocuments(handle.root)
25
28
  const bundle = await getBundle(handle.id)
26
29
  if (!loadBaseline(handle.root))
27
30
  captureBaseline(
@@ -35,7 +38,7 @@ export async function bootDomain(handle: DomainHandle): Promise<BootedDomain> {
35
38
  )
36
39
  const stop = watchDomain(handle)
37
40
  return {
38
- origin: bundle?.overlay.origin ?? handle.id,
41
+ origin: bundle?.ir?.domain ?? handle.origin ?? handle.id,
39
42
  depsInstalled: !!bundle?.depsInstalled,
40
43
  stop,
41
44
  }
@@ -0,0 +1,69 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { addDocument, listDocuments, migrateDocuments, readDocument } from './documents'
7
+ import { statePath, writeJson, writeStateBuffer } from './store'
8
+
9
+ const roots: string[] = []
10
+
11
+ function root(): string {
12
+ const created = mkdtempSync(join(tmpdir(), 'studio-documents-'))
13
+ roots.push(created)
14
+ return created
15
+ }
16
+
17
+ afterEach(() => {
18
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
19
+ })
20
+
21
+ const bytes = (text: string) => new TextEncoder().encode(text)
22
+
23
+ test('a document is stored under a readable name, not its id', () => {
24
+ const domain = root()
25
+ const meta = addDocument(domain, 'Pricing Decisions.MD', 'text/markdown', bytes('# pricing'))
26
+
27
+ expect(meta.stored).toBe('context/docs/pricing-decisions.md')
28
+ expect(readFileSync(statePath(domain, meta.stored), 'utf8')).toBe('# pricing')
29
+ expect(readDocument(domain, meta.id)?.meta.name).toBe('Pricing Decisions.MD')
30
+ })
31
+
32
+ test('same-named documents never overwrite each other', () => {
33
+ const domain = root()
34
+ const first = addDocument(domain, 'notes.md', 'text/markdown', bytes('first'))
35
+ const second = addDocument(domain, 'notes.md', 'text/markdown', bytes('second'))
36
+
37
+ expect([first.stored, second.stored]).toEqual([
38
+ 'context/docs/notes.md',
39
+ 'context/docs/notes-2.md',
40
+ ])
41
+ expect(readFileSync(statePath(domain, first.stored), 'utf8')).toBe('first')
42
+ })
43
+
44
+ test('uuid-named documents are migrated in place, once', () => {
45
+ const domain = root()
46
+ const legacy = 'context/documents/2b0d9d7e-1f2a-4a10-9f0c-1a2b3c4d5e6f.md'
47
+ writeStateBuffer(domain, legacy, bytes('legacy body'))
48
+ writeJson(domain, 'context/documents/index.json', [
49
+ {
50
+ id: '2b0d9d7e-1f2a-4a10-9f0c-1a2b3c4d5e6f',
51
+ name: 'Meeting notes.md',
52
+ type: 'text/markdown',
53
+ size: 11,
54
+ addedAt: new Date(0).toISOString(),
55
+ stored: legacy,
56
+ },
57
+ ])
58
+
59
+ migrateDocuments(domain)
60
+ const [migrated] = listDocuments(domain)
61
+
62
+ expect(migrated?.stored).toBe('context/docs/meeting-notes.md')
63
+ expect(readFileSync(statePath(domain, migrated!.stored), 'utf8')).toBe('legacy body')
64
+ expect(existsSync(statePath(domain, legacy))).toBe(false)
65
+
66
+ // idempotent: a second boot leaves the already-migrated store alone
67
+ migrateDocuments(domain)
68
+ expect(listDocuments(domain)[0]?.stored).toBe('context/docs/meeting-notes.md')
69
+ })
@@ -1,10 +1,11 @@
1
1
  /**
2
- * documents.ts — context DOCUMENTS the user drops in for the AI agent. Files are
3
- * stored under `.domain-studio/context/documents/` (allow-listed) and tracked in
4
- * an index. They travel with the domain and are part of the agent handoff context.
2
+ * documents.ts — context DOCUMENTS the user drops in for the AI agent. Files live
3
+ * under `.domain-studio/context/docs/`, named after the document (not its id), so
4
+ * the folder is readable by a human and by the agent that is handed its path.
5
+ * They travel with the domain and are part of the agent handoff context.
5
6
  */
6
7
  import { randomUUID } from 'node:crypto'
7
- import { existsSync } from 'node:fs'
8
+ import { existsSync, readFileSync } from 'node:fs'
8
9
  import { extname } from 'node:path'
9
10
 
10
11
  import type { DocMeta } from '../../shared/types'
@@ -13,7 +14,54 @@ import { asFiniteNumber, asJsonRecord, asString } from '../json'
13
14
  import { readJson, removeState, statePath, writeJson, writeStateBuffer } from './store'
14
15
 
15
16
  const INDEX = 'context/documents/index.json'
16
- const storedPath = (id: string, name: string) => `context/documents/${id}${extname(name)}`
17
+ const DIR = 'context/docs'
18
+ /** Where documents lived before they were named: `context/documents/<uuid>.<ext>`. */
19
+ const LEGACY_DIR = 'context/documents'
20
+
21
+ /** `Pricing decisions.md` → `pricing-decisions` — a file name you can read. */
22
+ function slugify(name: string): string {
23
+ const base = name.replace(/\.[^.]+$/, '')
24
+ const slug = base
25
+ .normalize('NFKD')
26
+ .replace(/[\u0300-\u036f]/g, '')
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9]+/g, '-')
29
+ .replace(/^-+|-+$/g, '')
30
+ .slice(0, 60)
31
+ return slug || 'document'
32
+ }
33
+
34
+ /** A stored path no other document (and no file on disk) already claims. */
35
+ function uniqueStoredPath(root: string, docs: DocMeta[], name: string): string {
36
+ const extension = extname(name).toLowerCase()
37
+ const slug = slugify(name)
38
+ const taken = new Set(docs.map((doc) => doc.stored))
39
+ for (let attempt = 0; ; attempt++) {
40
+ const candidate = `${DIR}/${slug}${attempt === 0 ? '' : `-${attempt + 1}`}${extension}`
41
+ if (!taken.has(candidate) && !existsSync(statePath(root, candidate))) return candidate
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Move documents written under the old uuid-named layout into `context/docs/`.
47
+ * Idempotent and only ever renames inside the studio's own state directory.
48
+ */
49
+ export function migrateDocuments(root: string): void {
50
+ const docs = listDocuments(root)
51
+ if (!docs.some((doc) => doc.stored.startsWith(`${LEGACY_DIR}/`))) return
52
+ let moved = false
53
+ for (const doc of docs) {
54
+ if (!doc.stored.startsWith(`${LEGACY_DIR}/`)) continue
55
+ const from = statePath(root, doc.stored)
56
+ if (!existsSync(from)) continue
57
+ const next = uniqueStoredPath(root, docs, doc.name)
58
+ writeStateBuffer(root, next, readFileSync(from))
59
+ removeState(root, doc.stored)
60
+ doc.stored = next
61
+ moved = true
62
+ }
63
+ if (moved) writeJson(root, INDEX, docs)
64
+ }
17
65
 
18
66
  function decodeDocument(value: unknown): DocMeta | undefined {
19
67
  const record = asJsonRecord(value)
@@ -30,7 +78,7 @@ function decodeDocument(value: unknown): DocMeta | undefined {
30
78
  size === undefined ||
31
79
  size < 0 ||
32
80
  !addedAt ||
33
- !stored?.startsWith('context/documents/') ||
81
+ !(stored?.startsWith(`${DIR}/`) || stored?.startsWith(`${LEGACY_DIR}/`)) ||
34
82
  stored.split('/').includes('..')
35
83
  ) {
36
84
  return undefined
@@ -60,18 +108,17 @@ export function listDocuments(root: string): DocMeta[] {
60
108
  }
61
109
 
62
110
  export function addDocument(root: string, name: string, type: string, data: Uint8Array): DocMeta {
63
- const id = randomUUID()
64
- const stored = storedPath(id, name)
111
+ const docs = listDocuments(root)
112
+ const stored = uniqueStoredPath(root, docs, name || 'untitled')
65
113
  writeStateBuffer(root, stored, data)
66
114
  const meta: DocMeta = {
67
- id,
115
+ id: randomUUID(),
68
116
  name: name || 'untitled',
69
117
  type: type || 'application/octet-stream',
70
118
  size: data.byteLength,
71
119
  addedAt: new Date().toISOString(),
72
120
  stored,
73
121
  }
74
- const docs = listDocuments(root)
75
122
  docs.unshift(meta)
76
123
  writeJson(root, INDEX, docs)
77
124
  return meta
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * We don't reimplement staleness: the `astrale` CLI owns it. `astrale update
5
5
  * --check --json`, run in the DOMAIN ROOT (so its @astrale-os/* SDK-dep axis sees
6
- * THIS project), emits a unified `{ stale, cli, sdk }` report. We run it with
6
+ * THIS project), emits a unified `{ stale, cli, skills, sdk }` report. We run it with
7
7
  * stdout piped and read it regardless of exit code — a stale result exits 10 by
8
8
  * design, which is not an error here. Best-effort: anything unexpected (no
9
9
  * bad output) collapses to "nothing stale" so the badge stays
@@ -16,6 +16,7 @@ import { decodeJsonObject, runStudioCliJson, runStudioCliText } from '../cli'
16
16
  const NOT_STALE: StaleReport = {
17
17
  stale: false,
18
18
  cli: { stale: false, managed: true },
19
+ skills: { status: 'current' },
19
20
  sdk: { stale: false, inProject: false, outdated: [] },
20
21
  }
21
22
 
@@ -30,6 +31,7 @@ export async function getUpdates(root: string): Promise<StaleReport> {
30
31
  function decodeStaleReport(value: unknown): StaleReport | null {
31
32
  const report = decodeJsonObject(value)
32
33
  const cli = decodeJsonObject(report?.cli)
34
+ const skills = decodeJsonObject(report?.skills)
33
35
  const sdk = decodeJsonObject(report?.sdk)
34
36
  if (
35
37
  typeof report?.stale !== 'boolean' ||
@@ -50,6 +52,22 @@ function decodeStaleReport(value: unknown): StaleReport | null {
50
52
  : []
51
53
  })
52
54
  if (outdated.length !== sdk.outdated.length) return null
55
+ const skillStatuses = new Set([
56
+ 'current',
57
+ 'update-available',
58
+ 'repair-needed',
59
+ 'unavailable',
60
+ 'skipped',
61
+ ])
62
+ // Additive compatibility: Studio can briefly run a newer server against an
63
+ // older CLI report while a binary update is being applied.
64
+ const skillStatus =
65
+ skills === null || skills === undefined
66
+ ? 'current'
67
+ : typeof skills.status === 'string' && skillStatuses.has(skills.status)
68
+ ? (skills.status as StaleReport['skills']['status'])
69
+ : null
70
+ if (skillStatus === null) return null
53
71
  return {
54
72
  stale: report.stale,
55
73
  cli: {
@@ -59,6 +77,10 @@ function decodeStaleReport(value: unknown): StaleReport | null {
59
77
  ...(typeof cli.latest === 'string' ? { latest: cli.latest } : {}),
60
78
  ...(typeof cli.channel === 'string' ? { channel: cli.channel } : {}),
61
79
  },
80
+ skills: {
81
+ status: skillStatus,
82
+ ...(typeof skills?.error === 'string' ? { error: skills.error } : {}),
83
+ },
62
84
  sdk: { stale: sdk.stale, inProject: sdk.inProject, outdated },
63
85
  }
64
86
  }
@@ -42,6 +42,10 @@ export interface StudioSettings {
42
42
  export interface StaleReport {
43
43
  stale: boolean
44
44
  cli: { stale: boolean; managed: boolean; current?: string; latest?: string; channel?: string }
45
+ skills: {
46
+ status: 'current' | 'update-available' | 'repair-needed' | 'unavailable' | 'skipped'
47
+ error?: string
48
+ }
45
49
  sdk: {
46
50
  stale: boolean
47
51
  inProject: boolean