@popcomputer/structured-chat 0.1.0 → 0.2.0-rc.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 (41) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +122 -108
  3. package/dist/adapters/openai-compatible-model.d.ts +4 -4
  4. package/dist/adapters/openai-compatible-model.js +33 -31
  5. package/dist/core/answer.d.ts +13 -13
  6. package/dist/core/answer.js +21 -12
  7. package/dist/core/chat.d.ts +12 -15
  8. package/dist/core/chat.js +36 -27
  9. package/dist/core/collect-stage.d.ts +11 -14
  10. package/dist/core/collect-stage.js +40 -37
  11. package/dist/core/command.d.ts +1 -1
  12. package/dist/core/command.js +1 -1
  13. package/dist/core/json-value.d.ts +1 -1
  14. package/dist/core/json-value.js +8 -1
  15. package/dist/core/model-guard.d.ts +2 -3
  16. package/dist/core/model-guard.js +4 -4
  17. package/dist/core/model.d.ts +15 -19
  18. package/dist/core/model.js +22 -10
  19. package/dist/core/protocol.d.ts +84 -79
  20. package/dist/core/protocol.js +18 -12
  21. package/dist/core/question.js +17 -15
  22. package/dist/core/repair.js +1 -1
  23. package/dist/core/session.d.ts +22 -28
  24. package/dist/core/session.js +16 -7
  25. package/dist/core/stage-name.d.ts +1 -1
  26. package/dist/core/stage-name.js +1 -1
  27. package/dist/core/stage.d.ts +4 -4
  28. package/dist/core/stage.js +11 -8
  29. package/dist/core/tool-set.js +6 -6
  30. package/dist/core/tool.d.ts +36 -39
  31. package/dist/core/tool.js +58 -31
  32. package/dist/core/view.d.ts +64 -39
  33. package/dist/core/view.js +12 -15
  34. package/dist/integrations/assistant-ui.js +21 -14
  35. package/dist/testing/in-memory-session-store.js +1 -1
  36. package/dist/testing/scenario.js +6 -6
  37. package/examples/answer-modes.ts +60 -49
  38. package/examples/prompt-injection-policy.ts +20 -20
  39. package/examples/resource-search.ts +104 -0
  40. package/package.json +11 -3
  41. package/examples/agency-search.ts +0 -101
@@ -9,84 +9,95 @@ import {
9
9
 
10
10
  const SearchCatalog = defineTool({
11
11
  name: "search_catalog",
12
- description: "Search the catalog for relevant agency matches.",
13
- input: Schema.Struct({ query: Schema.NonEmptyTrimmedString }),
12
+ description: "Search the catalog for relevant resources.",
13
+ input: Schema.Struct({
14
+ query: Schema.Trimmed.check(Schema.isNonEmpty()),
15
+ }),
14
16
  execute: ({ query }) => Effect.succeed({ query }),
15
17
  })
16
18
 
17
19
  const Search = Stage.tools({
18
20
  name: "search",
19
- instructions: ["Search the catalog using the completed project brief."],
21
+ instructions: ["Search the catalog using the completed request details."],
20
22
  tools: [SearchCatalog],
21
23
  })
22
24
 
23
- const ProjectBrief = Stage.collect({
24
- name: "project_brief",
25
+ const RequestDetails = Stage.collect({
26
+ name: "request_details",
25
27
  questions: {
26
28
  guidance: "Ask one concise, conversational question at a time.",
27
29
  escape: "Not sure yet",
28
30
  },
29
31
  fields: {
30
- priority: Answer.confirmed(Schema.NonEmptyTrimmedString, {
31
- description: "Where the client most needs outside help.",
32
- ask: Question.adaptiveChoice(
33
- "Where could an agency make the biggest difference?",
34
- {
35
- minimumOptions: 2,
36
- maximumOptions: 3,
37
- fallbackOptions: [
38
- "Launch or grow a product",
39
- "Build the brand long term",
40
- "Fix a performance problem",
41
- ],
42
- },
43
- ),
44
- }),
45
- location: Answer.confirmed(Schema.NonEmptyTrimmedString, {
46
- description: "Where the client is based.",
47
- ask: Question.adaptive(
48
- "Ask where the client is based and whether location matters.",
49
- {
50
- fallback: "Where are you based, and does location matter?",
51
- },
52
- ),
53
- }),
32
+ goal: Answer.confirmed(
33
+ Schema.Trimmed.check(Schema.isNonEmpty()),
34
+ {
35
+ description: "The outcome the user wants to achieve.",
36
+ ask: Question.adaptiveChoice(
37
+ "What would you like help accomplishing?",
38
+ {
39
+ minimumOptions: 2,
40
+ maximumOptions: 3,
41
+ fallbackOptions: [
42
+ "Understand a topic",
43
+ "Compare available options",
44
+ "Plan the next steps",
45
+ ],
46
+ },
47
+ ),
48
+ },
49
+ ),
50
+ audience: Answer.confirmed(
51
+ Schema.Trimmed.check(Schema.isNonEmpty()),
52
+ {
53
+ description: "Who the requested result is for.",
54
+ ask: Question.adaptive(
55
+ "Ask who will use the result and what they already know.",
56
+ {
57
+ fallback: "Who is this for, and what do they already know?",
58
+ },
59
+ ),
60
+ },
61
+ ),
54
62
  },
55
63
  })
56
64
 
57
65
  /** A required ask-then-answer workflow before catalog search. */
58
- export const Matchmaker = defineChat({
59
- name: "matchmaker",
66
+ export const GuidedResourceFinder = defineChat({
67
+ name: "guided_resource_finder",
60
68
  version: 1,
61
- stages: [ProjectBrief, Search],
69
+ stages: [RequestDetails, Search],
62
70
  })
63
71
 
64
- const ProjectUnderstanding = Stage.collect({
65
- name: "project_understanding",
72
+ const RequestUnderstanding = Stage.collect({
73
+ name: "request_understanding",
66
74
  fields: {
67
- intent: Answer.semantic(Schema.NonEmptyTrimmedString, {
68
- description:
69
- "The client's searchable need, desired outcome, sector, or relevant experience.",
70
- ask: Question.adaptive(
71
- "Ask one useful follow-up only when the request is not searchable yet.",
72
- {
73
- fallback: "What outcome or experience should I search for?",
74
- },
75
- ),
76
- }),
75
+ intent: Answer.semantic(
76
+ Schema.Trimmed.check(Schema.isNonEmpty()),
77
+ {
78
+ description:
79
+ "The user's searchable goal, subject, constraints, or desired outcome.",
80
+ ask: Question.adaptive(
81
+ "Ask one useful follow-up only when the request is not searchable yet.",
82
+ {
83
+ fallback: "What subject or outcome should I search for?",
84
+ },
85
+ ),
86
+ },
87
+ ),
77
88
  },
78
89
  })
79
90
 
80
91
  /** A chat that can infer a searchable intent from the opening request. */
81
- export const FreeformMatchmaker = defineChat({
82
- name: "freeform_matchmaker",
92
+ export const FreeformResourceFinder = defineChat({
93
+ name: "freeform_resource_finder",
83
94
  version: 1,
84
- stages: [ProjectUnderstanding, Search],
95
+ stages: [RequestUnderstanding, Search],
85
96
  })
86
97
 
87
98
  /** A chat whose catalog search tool is available on the opening turn. */
88
- export const OpenSearchChat = defineChat({
89
- name: "open_search_chat",
99
+ export const OpenResourceSearch = defineChat({
100
+ name: "open_resource_search",
90
101
  version: 1,
91
102
  stages: [Search],
92
103
  })
@@ -21,9 +21,7 @@ export class UnsafeConversation extends Schema.TaggedError<UnsafeConversation>()
21
21
  * Application-owned classifier; production may use any local or remote
22
22
  * policy.
23
23
  */
24
- export class ConversationSafety extends Context.Tag(
25
- "ConversationSafety",
26
- )<
24
+ export class ConversationSafety extends Context.Service<
27
25
  ConversationSafety,
28
26
  {
29
27
  readonly checkConversation: (
@@ -33,34 +31,36 @@ export class ConversationSafety extends Context.Tag(
33
31
  context: ModelGuardCallContext,
34
32
  ) => Effect.Effect<void, UnsafeConversation>
35
33
  }
36
- >() {}
34
+ >()("ConversationSafety") {}
37
35
 
38
36
  /** Effect-native guard composed into any model-backed stage. */
39
37
  export const PromptInjectionPolicy = defineModelGuard({
40
38
  name: "prompt_injection_policy",
41
39
  check: (context) =>
42
- ConversationSafety.pipe(
43
- Effect.flatMap((safety) =>
44
- safety.checkConversation(context),
45
- ),
46
- ),
40
+ Effect.gen(function* () {
41
+ const safety = yield* ConversationSafety
42
+ return yield* safety.checkConversation(context)
43
+ }),
47
44
  checkCall: (context) =>
48
- ConversationSafety.pipe(
49
- Effect.flatMap((safety) => safety.checkCall(context)),
50
- ),
45
+ Effect.gen(function* () {
46
+ const safety = yield* ConversationSafety
47
+ return yield* safety.checkCall(context)
48
+ }),
51
49
  })
52
50
 
53
- const SearchAgencies = defineTool({
54
- name: "search_agencies",
55
- description: "Find agencies relevant to the project brief.",
56
- input: Schema.Struct({ query: Schema.NonEmptyTrimmedString }),
51
+ const FindResources = defineTool({
52
+ name: "find_resources",
53
+ description: "Find resources relevant to the request.",
54
+ input: Schema.Struct({
55
+ query: Schema.Trimmed.check(Schema.isNonEmpty()),
56
+ }),
57
57
  execute: ({ query }) => Effect.succeed({ query }),
58
58
  })
59
59
 
60
60
  /** Closed search stage protected by the application policy. */
61
- export const Matching = Stage.tools({
62
- name: "matching",
63
- instructions: ["Route the completed brief to one search."],
64
- tools: [SearchAgencies],
61
+ export const Lookup = Stage.tools({
62
+ name: "lookup",
63
+ instructions: ["Route the completed request to one resource lookup."],
64
+ tools: [FindResources],
65
65
  guards: [PromptInjectionPolicy],
66
66
  })
@@ -0,0 +1,104 @@
1
+ import {
2
+ Context,
3
+ Effect,
4
+ Layer,
5
+ Schema,
6
+ } from "effect"
7
+ import {
8
+ defineTool,
9
+ defineView,
10
+ Message,
11
+ Stage,
12
+ Tool,
13
+ } from "@popcomputer/structured-chat"
14
+
15
+ interface ResourceMatch {
16
+ readonly id: string
17
+ readonly title: string
18
+ readonly summary: string
19
+ readonly internalScore: number
20
+ readonly retrievedText: string
21
+ }
22
+
23
+ class ResourceCatalog extends Context.Service<
24
+ ResourceCatalog,
25
+ {
26
+ readonly search: (
27
+ query: string,
28
+ ) => Effect.Effect<ReadonlyArray<ResourceMatch>>
29
+ }
30
+ >()("ResourceCatalog") {}
31
+
32
+ const ResultCards = defineView({
33
+ name: "result_cards",
34
+ version: 1,
35
+ schema: Schema.Struct({
36
+ results: Schema.Array(
37
+ Schema.Struct({
38
+ id: Schema.String,
39
+ title: Schema.String,
40
+ summary: Schema.String,
41
+ }),
42
+ ),
43
+ }),
44
+ })
45
+
46
+ const ResourceEvidence = Schema.Struct({
47
+ matches: Schema.Array(
48
+ Schema.Struct({
49
+ id: Schema.String,
50
+ summary: Schema.String,
51
+ }),
52
+ ),
53
+ })
54
+
55
+ export const FindResources = defineTool({
56
+ name: "find_resources",
57
+ description: "Find resources relevant to the request.",
58
+ input: Schema.Struct({
59
+ query: Schema.Trimmed.check(Schema.isNonEmpty()),
60
+ }),
61
+ execute: ({ query }) =>
62
+ Effect.gen(function* () {
63
+ const catalog = yield* ResourceCatalog
64
+ return yield* catalog.search(query)
65
+ }),
66
+ }).pipe(
67
+ Tool.modelResult(ResourceEvidence, (matches) => ({
68
+ matches: matches.map(({ id, summary }) => ({ id, summary })),
69
+ })),
70
+ Tool.present(ResultCards, (matches) => ({
71
+ results: matches.map(({ id, title, summary }) => ({
72
+ id,
73
+ title,
74
+ summary,
75
+ })),
76
+ })),
77
+ )
78
+
79
+ export const Lookup = Stage.tools({
80
+ name: "lookup",
81
+ instructions: ["Route the completed request to one resource lookup."],
82
+ tools: [FindResources],
83
+ })
84
+
85
+ const ResourceCatalogLive = Layer.succeed(ResourceCatalog, {
86
+ search: () =>
87
+ Effect.succeed([
88
+ {
89
+ id: "resource:1",
90
+ title: "Onboarding checklist",
91
+ summary: "A concise sequence of practical onboarding steps.",
92
+ internalScore: 0.93,
93
+ retrievedText: "Untrusted source text remains server-side.",
94
+ },
95
+ ]),
96
+ })
97
+
98
+ export const example = FindResources.execute({
99
+ query: "A practical onboarding guide",
100
+ }).pipe(Effect.provide(ResourceCatalogLive))
101
+
102
+ export const stageExample = Lookup.run([
103
+ Message.user("We need a practical onboarding guide."),
104
+ ])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popcomputer/structured-chat",
3
- "version": "0.1.0",
3
+ "version": "0.2.0-rc.0",
4
4
  "description": "Schema-defined structured chats with typed tools, stages, and UI views",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.1",
@@ -57,13 +57,21 @@
57
57
  ],
58
58
  "author": "Patrick Ogilvie",
59
59
  "license": "MIT",
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "git+https://github.com/PatrickOgilvie/popcomputer-structured-chat.git"
63
+ },
64
+ "homepage": "https://github.com/PatrickOgilvie/popcomputer-structured-chat#readme",
65
+ "bugs": {
66
+ "url": "https://github.com/PatrickOgilvie/popcomputer-structured-chat/issues"
67
+ },
60
68
  "publishConfig": {
61
69
  "access": "public"
62
70
  },
63
71
  "peerDependencies": {
64
72
  "@assistant-ui/core": "^0.3.1",
65
73
  "react": "^18.0.0 || ^19.0.0",
66
- "effect": "^3.21.2"
74
+ "effect": "^4.0.0-rc.109"
67
75
  },
68
76
  "peerDependenciesMeta": {
69
77
  "@assistant-ui/core": {
@@ -80,7 +88,7 @@
80
88
  "@types/bun": "^1.3.14",
81
89
  "@types/react": "^19.2.17",
82
90
  "@types/react-dom": "^19.2.3",
83
- "effect": "3.21.2",
91
+ "effect": "4.0.0-rc.109",
84
92
  "oxlint": "1.78.0",
85
93
  "react": "^19.2.4",
86
94
  "react-dom": "^19.2.4",
@@ -1,101 +0,0 @@
1
- import {
2
- Context,
3
- Effect,
4
- Layer,
5
- Schema,
6
- } from "effect"
7
- import {
8
- defineTool,
9
- defineView,
10
- Message,
11
- Stage,
12
- Tool,
13
- } from "@popcomputer/structured-chat"
14
-
15
- interface AgencyMatch {
16
- readonly id: string
17
- readonly name: string
18
- readonly reason: string
19
- readonly internalScore: number
20
- readonly retrievedText: string
21
- }
22
-
23
- class AgencyCatalog extends Context.Tag("AgencyCatalog")<
24
- AgencyCatalog,
25
- {
26
- readonly search: (
27
- query: string,
28
- ) => Effect.Effect<ReadonlyArray<AgencyMatch>>
29
- }
30
- >() {}
31
-
32
- const AgencyCards = defineView({
33
- name: "agency_cards",
34
- version: 1,
35
- schema: Schema.Struct({
36
- agencies: Schema.Array(
37
- Schema.Struct({
38
- id: Schema.String,
39
- name: Schema.String,
40
- reason: Schema.String,
41
- }),
42
- ),
43
- }),
44
- })
45
-
46
- const AgencyEvidence = Schema.Struct({
47
- matches: Schema.Array(
48
- Schema.Struct({
49
- id: Schema.String,
50
- reason: Schema.String,
51
- }),
52
- ),
53
- })
54
-
55
- export const SearchAgencies = defineTool({
56
- name: "search_agencies",
57
- description: "Find agencies relevant to the project.",
58
- input: Schema.Struct({ query: Schema.NonEmptyTrimmedString }),
59
- execute: ({ query }) =>
60
- AgencyCatalog.pipe(
61
- Effect.flatMap((catalog) => catalog.search(query)),
62
- ),
63
- }).pipe(
64
- Tool.modelResult(AgencyEvidence, (matches) => ({
65
- matches: matches.map(({ id, reason }) => ({ id, reason })),
66
- })),
67
- Tool.present(AgencyCards, (matches) => ({
68
- agencies: matches.map(({ id, name, reason }) => ({
69
- id,
70
- name,
71
- reason,
72
- })),
73
- })),
74
- )
75
-
76
- export const Matching = Stage.tools({
77
- name: "matching",
78
- instructions: ["Route the project brief to one agency search."],
79
- tools: [SearchAgencies],
80
- })
81
-
82
- const AgencyCatalogLive = Layer.succeed(AgencyCatalog, {
83
- search: () =>
84
- Effect.succeed([
85
- {
86
- id: "agency:1",
87
- name: "Northbank",
88
- reason: "Relevant public-sector transformation work.",
89
- internalScore: 0.93,
90
- retrievedText: "Untrusted source text remains server-side.",
91
- },
92
- ]),
93
- })
94
-
95
- export const example = SearchAgencies.execute({
96
- query: "A public-sector digital service",
97
- }).pipe(Effect.provide(AgencyCatalogLive))
98
-
99
- export const stageExample = Matching.run([
100
- Message.user("We need a public-sector digital service."),
101
- ])